feat(system): 新增登录日志和操作日志功能模块

- 新增登录日志管理页面,支持日志查询、删除等操作
- 新增操作日志管理页面,提供系统操作审计功能
- 实现完整的日志API接口和类型定义
- 配置相应的权限控制和路由管理
- 完善系统日志管理体系,提升系统安全性
This commit is contained in:
Leo 2025-07-08 10:55:57 +08:00
parent 03c68be25f
commit 11938a1067
4 changed files with 1839 additions and 0 deletions

View File

@ -0,0 +1,61 @@
import { request } from '../../../http'
import type { OperLogQueryBo, OperLogVo } from './types'
// 重新导出类型供外部使用
export type { OperLogForm, OperLogQueryBo, OperLogSearchForm, OperLogVo } from './types'
/**
*
*/
export function getOperLogListPage(params: OperLogQueryBo) {
return request.Get<Service.ResponseResult<Service.PageResult<OperLogVo>>>('/coder/sysOperLog/listPage', { params })
}
/**
* ID查询操作日志详情
*/
export function getOperLogById(operId: number) {
return request.Get<Service.ResponseResult<OperLogVo>>(`/coder/sysOperLog/getById/${operId}`)
}
/**
*
*/
export function getOperLogDetailById(operId: number) {
return request.Get<Service.ResponseResult<OperLogVo>>(`/coder/sysOperLog/getDetailById/${operId}`)
}
/**
*
*/
export function deleteOperLog(operId: number) {
return request.Post<Service.ResponseResult<null>>(`/coder/sysOperLog/deleteById/${operId}`)
}
/**
*
*/
export function batchDeleteOperLog(operIds: number[]) {
return request.Post<Service.ResponseResult<null>>('/coder/sysOperLog/batchDelete', operIds)
}
/**
*
*/
export function clearOperLog() {
return request.Post<Service.ResponseResult<null>>('/coder/sysOperLog/clear')
}
/**
*
*/
export function getOperLogStatistics() {
return request.Get<Service.ResponseResult<Record<string, any>>>('/coder/sysOperLog/statistics')
}
/**
*
*/
export function getOperLogDashboard() {
return request.Get<Service.ResponseResult<Record<string, any>>>('/coder/sysOperLog/dashboard')
}

View File

@ -0,0 +1,71 @@
/**
*
*/
// 操作日志查询参数
export interface OperLogQueryBo {
pageNo?: number
pageSize?: number
operName?: string // 操作名称
operMan?: string // 操作人员
operType?: string // 操作类型
operStatus?: string // 操作状态 (0成功 1失败)
operUrl?: string // 请求URL
requestMethod?: string // 请求方式
operIp?: string // 操作IP
beginTime?: string // 开始时间
endTime?: string // 结束时间
}
// 操作日志响应数据
export interface OperLogVo {
operId: number // 操作主键
operName: string // 操作名称
operType: string // 操作类型
methodName: string // 方法名称
requestMethod?: string // 请求方式
systemType?: string // 系统类型
operMan: string // 操作人员
operUrl: string // 请求URL
operIp: string // 主机地址
operLocation?: string // 操作地点
operParam?: string // 请求参数
jsonResult?: string // 返回参数
operStatus: string // 操作状态 (0成功 1失败)
errorMsg?: string // 错误消息
operTime: string // 操作时间
costTime?: string // 消耗时间
}
// 操作日志表单数据
export interface OperLogForm {
operId?: number
operName: string
operType: string
methodName: string
requestMethod?: string
systemType?: string
operMan: string
operUrl: string
operIp: string
operLocation?: string
operParam?: string
jsonResult?: string
operStatus: string
errorMsg?: string
costTime?: string
}
// 操作日志搜索表单
export interface OperLogSearchForm {
operName?: string
operMan?: string
operType?: string
operStatus?: string | null
operUrl?: string
requestMethod?: string
operIp?: string
timeRange?: [number, number] | null
beginTime?: string
endTime?: string
}

View File

@ -0,0 +1,739 @@
<template>
<div class="loginlog-management p-1 bg-gray-50 h-screen flex flex-col">
<!-- 搜索表单和登录日志表格 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 flex-1 flex flex-col overflow-hidden">
<!-- 搜索表单 -->
<div class="px-4 py-2 border-b border-gray-100">
<n-form
ref="searchFormRef"
:model="searchForm"
label-placement="left"
label-width="auto"
class="search-form"
>
<n-grid :cols="6" :x-gap="8" :y-gap="4">
<n-grid-item>
<n-form-item label="用户名称" path="loginName">
<n-input
v-model:value="searchForm.loginName"
placeholder="请输入用户名称"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="登录IP" path="loginIp">
<n-input
v-model:value="searchForm.loginIp"
placeholder="请输入登录IP"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="设备名称" path="deviceName">
<n-input
v-model:value="searchForm.deviceName"
placeholder="请输入设备名称"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="登录状态" path="loginStatus">
<n-select
v-model:value="searchForm.loginStatus"
placeholder="请选择登录状态"
clearable
:options="[
{ label: '成功', value: '0' },
{ label: '失败', value: '1' },
]"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="登录时间" path="timeRange">
<n-date-picker
v-model:value="searchForm.timeRange"
type="datetimerange"
clearable
placeholder="选择时间范围"
:shortcuts="{
今天: () => [new Date().setHours(0, 0, 0, 0), new Date().setHours(23, 59, 59, 999)],
昨天: () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return [yesterday.setHours(0, 0, 0, 0), yesterday.setHours(23, 59, 59, 999)];
},
最近7天: () => [Date.now() - 7 * 24 * 60 * 60 * 1000, Date.now()],
最近30天: () => [Date.now() - 30 * 24 * 60 * 60 * 1000, Date.now()],
}"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="" path="">
<NSpace>
<NButton type="primary" @click="handleSearch">
<template #icon>
<NIcon><icon-park-outline:search /></NIcon>
</template>
搜索
</NButton>
<NButton @click="handleReset">
<template #icon>
<NIcon><icon-park-outline:refresh /></NIcon>
</template>
重置
</NButton>
</NSpace>
</n-form-item>
</n-grid-item>
</n-grid>
</n-form>
</div>
<!-- 表格头部操作栏 -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<div class="flex items-center gap-4">
<NButton
v-permission="PERMISSIONS.LOGIN_LOG.DELETE"
type="error"
:disabled="selectedRows.length === 0"
class="px-3 flex items-center"
@click="handleBatchDelete"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:delete />
</NIcon>
</template>
删除
</NButton>
</div>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span> {{ pagination.itemCount }} </span>
<NButton text @click="getLoginLogList">
<template #icon>
<NIcon><icon-park-outline:refresh /></NIcon>
</template>
</NButton>
</div>
</div>
<!-- 表格内容 -->
<div class="table-wrapper flex-1 overflow-auto pt-4">
<!-- 数据表格 -->
<n-data-table
v-if="tableData.length > 0 || loading"
:columns="columns"
:data="tableData"
:loading="loading"
:row-key="(row: LoginLogVo) => row.loginLogId"
:bordered="false"
:single-line="false"
size="large"
class="custom-table"
@update:checked-row-keys="handleRowSelectionChange"
/>
<!-- 空状态 -->
<CoiEmpty
v-else
:type="getEmptyType()"
:title="getEmptyTitle()"
:description="getEmptyDescription()"
:show-action="true"
:action-text="getEmptyActionText()"
size="medium"
@action="handleEmptyAction"
>
<template #action>
<NButton
type="primary"
size="medium"
round
class="coi-empty__action-btn"
@click="handleEmptyAction"
>
<template #icon>
<NIcon>
<icon-park-outline:refresh />
</NIcon>
</template>
{{ getEmptyActionText() }}
</NButton>
</template>
</CoiEmpty>
</div>
<!-- 分页器 -->
<div v-if="tableData.length > 0" class="flex items-center px-4 py-2 border-t border-gray-100">
<div class="text-sm text-gray-500 mr-4">
{{ pagination.itemCount }}
</div>
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:show-size-picker="true"
:page-sizes="[10, 20, 50, 100]"
show-quick-jumper
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { h, nextTick, onMounted, ref } from 'vue'
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NSpace, NTag, NTooltip } from 'naive-ui'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import {
batchDeleteLoginLog,
deleteLoginLog,
getLoginLogListPage,
} from '@/service/api/system/loginlog'
import type { LoginLogVo } from '@/service/api/system/loginlog'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { PERMISSIONS } from '@/constants/permissions'
import { usePermission } from '@/hooks/usePermission'
//
const { hasPermission } = usePermission()
//
interface LoginLogSearchForm {
loginName?: string
loginIp?: string
deviceName?: string
loginStatus?: string | null
timeRange?: [number, number] | null
beginTime?: string
endTime?: string
}
//
const loading = ref(false)
const tableData = ref<LoginLogVo[]>([])
const searchFormRef = ref<FormInst | null>(null)
const selectedRows = ref<LoginLogVo[]>([])
//
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
})
//
const searchForm = ref<LoginLogSearchForm>({})
//
const columns: DataTableColumns<LoginLogVo> = [
{
type: 'selection',
width: 50,
},
{
title: '序号',
key: 'index',
width: 70,
align: 'center',
render: (_, index) => {
return (pagination.value.page - 1) * pagination.value.pageSize + index + 1
},
},
{
title: '登录用户',
key: 'loginName',
width: 120,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-600 font-medium' }, row.loginName)
},
},
{
title: '登录IP',
key: 'loginIp',
width: 140,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.loginIp)
},
},
{
title: '登录地址',
key: 'loginAddress',
width: 150,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-500' }, row.loginAddress || '-')
},
},
{
title: '浏览器',
key: 'loginBrowser',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-500' }, row.browser || '-')
},
},
{
title: '操作系统',
key: 'loginOs',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-500' }, row.os || '-')
},
},
{
title: '设备名称',
key: 'deviceName',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-500' }, row.deviceName || '-')
},
},
{
title: '登录状态',
key: 'loginStatus',
width: 100,
align: 'center',
render: (row) => {
const isSuccess = row.loginStatus === '0'
return h(NTag, {
type: isSuccess ? 'success' : 'error',
size: 'small',
}, {
default: () => isSuccess ? '操作成功' : '操作失败',
})
},
},
{
title: '登录信息',
key: 'loginMsg',
width: 150,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-500' }, row.message || '-')
},
},
{
title: '登录时间',
key: 'loginTime',
width: 180,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-500 text-sm' }, row.loginTime)
},
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'center',
fixed: 'right',
render: (row) => {
const buttons = []
//
if (hasPermission(PERMISSIONS.LOGIN_LOG.DELETE)) {
buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleDelete(row.loginLogId),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => '确定删除此登录记录吗?',
trigger: () => h(NTooltip, {
trigger: 'hover',
}, {
default: () => '删除',
trigger: () => h(NButton, {
type: 'error',
size: 'medium',
circle: true,
class: 'action-btn action-btn-delete',
}, {
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlineDelete) }),
}),
}),
}))
}
return h('div', { class: 'flex items-center justify-center gap-2' }, buttons)
},
},
]
//
async function getLoginLogList() {
loading.value = true
try {
//
const { timeRange, ...otherParams } = searchForm.value
// null
const filteredParams = Object.entries(otherParams).reduce((acc, [key, value]) => {
if (value !== null && value !== undefined && value !== '') {
acc[key] = value
}
return acc
}, {} as Record<string, any>)
const params: Record<string, any> = {
pageNo: pagination.value.page,
pageSize: pagination.value.pageSize,
...filteredParams,
}
// beginTimeendTime
if (timeRange && timeRange.length === 2) {
params.beginTime = new Date(timeRange[0]).toISOString().slice(0, 19).replace('T', ' ')
params.endTime = new Date(timeRange[1]).toISOString().slice(0, 19).replace('T', ' ')
}
const { isSuccess, data } = await getLoginLogListPage(params)
if (isSuccess && data) {
tableData.value = data.records || []
pagination.value.itemCount = data.total || 0
}
else {
console.warn('获取登录日志列表失败,可能是权限或网络问题')
coiMsgError('获取登录日志列表失败,请检查网络连接或联系管理员')
tableData.value = []
pagination.value.itemCount = 0
}
}
catch (error) {
console.error('获取登录日志列表失败:', error)
coiMsgError('获取登录日志列表失败,请检查网络连接')
tableData.value = []
pagination.value.itemCount = 0
}
finally {
loading.value = false
}
}
//
function handlePageChange(page: number) {
pagination.value.page = page
getLoginLogList()
}
function handlePageSizeChange(pageSize: number) {
pagination.value.pageSize = pageSize
pagination.value.page = 1
getLoginLogList()
}
//
function handleSearch() {
pagination.value.page = 1
getLoginLogList()
}
//
async function handleReset() {
//
searchForm.value = {
loginName: '',
loginIp: '',
deviceName: '',
loginStatus: null,
beginTime: '',
endTime: '',
timeRange: null,
}
// 使 nextTick DOM
await nextTick()
//
if (searchFormRef.value) {
searchFormRef.value.restoreValidation()
}
//
pagination.value.page = 1
//
getLoginLogList()
}
//
function handleRowSelectionChange(rowKeys: number[]) {
selectedRows.value = tableData.value.filter(row => rowKeys.includes(row.loginLogId))
}
//
async function handleDelete(loginLogId: number) {
try {
const { isSuccess } = await deleteLoginLog(loginLogId)
if (isSuccess) {
coiMsgSuccess('删除成功')
await getLoginLogList()
}
else {
coiMsgError('删除失败')
}
}
catch (error) {
console.error('删除失败:', error)
coiMsgError('删除失败')
}
}
//
async function handleBatchDelete() {
if (selectedRows.value.length === 0) {
coiMsgWarning('请先选择要删除的登录记录')
return
}
try {
//
await coiMsgBox(`确定要删除选中的 ${selectedRows.value.length} 条登录记录吗?`, '批量删除确认')
}
catch {
//
return
}
//
try {
const logIds = selectedRows.value.map(log => log.loginLogId)
const { isSuccess } = await batchDeleteLoginLog(logIds)
if (isSuccess) {
coiMsgSuccess('批量删除成功')
selectedRows.value = []
await getLoginLogList()
}
else {
coiMsgError('批量删除失败')
}
}
catch (error) {
console.error('批量删除失败:', error)
coiMsgError('批量删除失败')
}
}
//
function hasSearchConditions() {
return Object.values(searchForm.value).some((value) => {
if (value === null || value === undefined || value === '') {
return false
}
if (Array.isArray(value) && value.length === 0) {
return false
}
return true
})
}
//
function getEmptyType() {
if (hasSearchConditions()) {
return 'search'
}
return 'default'
}
//
function getEmptyTitle() {
if (hasSearchConditions()) {
return '搜索无结果'
}
return '暂无登录日志'
}
//
function getEmptyDescription() {
if (hasSearchConditions()) {
return '未找到符合搜索条件的登录记录,请尝试调整搜索条件或重置筛选'
}
return '当前还没有登录日志数据'
}
//
function getEmptyActionText() {
if (hasSearchConditions()) {
return '重置筛选'
}
return '刷新数据'
}
//
function handleEmptyAction() {
if (hasSearchConditions()) {
handleReset()
}
else {
getLoginLogList()
}
}
//
onMounted(() => {
getLoginLogList()
})
</script>
<style scoped>
.loginlog-management {
height: 100vh;
overflow: hidden;
}
.custom-table :deep(.n-data-table-td) {
padding: 8px 12px;
}
.custom-table :deep(.n-data-table-th) {
padding: 10px 12px;
background-color: #fafafa;
font-weight: normal;
color: #262626;
}
.custom-table :deep(.n-data-table-tr:hover .n-data-table-td) {
background-color: #f8faff;
}
.search-form :deep(.n-form-item-label) {
font-weight: 500;
color: #262626;
}
/* CoiEmpty按钮样式 */
.coi-empty__action-btn {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border: none;
}
.coi-empty__action-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.6s ease;
}
.coi-empty__action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.coi-empty__action-btn:hover::before {
left: 100%;
}
/* 操作按钮样式 */
.action-btn {
width: 36px !important;
height: 36px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 50% !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
border: none !important;
position: relative !important;
overflow: hidden !important;
}
.action-btn:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15) !important;
}
.action-btn:active {
transform: translateY(0) !important;
transition: all 0.1s !important;
}
/* 删除按钮 */
.action-btn-delete {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
color: white !important;
}
.action-btn-delete:hover {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4) !important;
}
/* 信息按钮 */
.action-btn-info {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%) !important;
color: white !important;
}
.action-btn-info:hover {
background: linear-gradient(135deg, #0891b2 0%, #0e7490 100%) !important;
box-shadow: 0 4px 16px rgba(6, 182, 212, 0.4) !important;
}
/* 图标居中对齐 */
.action-btn .n-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 !important;
}
/* 移除默认边框和背景 */
.action-btn.n-button {
border: none !important;
}
.action-btn.n-button:focus {
outline: none !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) !important;
}
/* 登录日志详情样式 */
.login-log-detail :deep(.n-descriptions-item-label) {
font-weight: 600;
color: #374151;
background-color: #f9fafb;
}
.login-log-detail :deep(.n-descriptions-item-content) {
color: #6b7280;
}
</style>

View File

@ -0,0 +1,968 @@
<template>
<div class="operlog-management p-1 bg-gray-50 h-screen flex flex-col">
<!-- 搜索表单和操作日志表格 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 flex-1 flex flex-col overflow-hidden">
<!-- 搜索表单 -->
<div class="px-4 py-2 border-b border-gray-100">
<n-form
ref="searchFormRef"
:model="searchForm"
label-placement="left"
label-width="auto"
class="search-form"
>
<n-grid :cols="7" :x-gap="8" :y-gap="4">
<n-grid-item>
<n-form-item label="操作名称" path="operName">
<n-input
v-model:value="searchForm.operName"
placeholder="请输入操作名称"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="操作人员" path="operMan">
<n-input
v-model:value="searchForm.operMan"
placeholder="请输入操作人员"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="操作状态" path="operStatus">
<n-select
v-model:value="searchForm.operStatus"
placeholder="请选择操作状态"
clearable
:options="[
{ label: '操作成功', value: '0' },
{ label: '操作失败', value: '1' },
]"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="操作IP" path="operIp">
<n-input
v-model:value="searchForm.operIp"
placeholder="请输入操作IP"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="操作时间" path="timeRange">
<n-date-picker
v-model:value="searchForm.timeRange"
type="datetimerange"
clearable
placeholder="选择时间范围"
:shortcuts="{
今天: () => [new Date().setHours(0, 0, 0, 0), new Date().setHours(23, 59, 59, 999)],
昨天: () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return [yesterday.setHours(0, 0, 0, 0), yesterday.setHours(23, 59, 59, 999)];
},
最近7天: () => [Date.now() - 7 * 24 * 60 * 60 * 1000, Date.now()],
最近30天: () => [Date.now() - 30 * 24 * 60 * 60 * 1000, Date.now()],
}"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="" path="">
<NSpace>
<NButton type="primary" @click="handleSearch">
<template #icon>
<NIcon><icon-park-outline:search /></NIcon>
</template>
搜索
</NButton>
<NButton @click="handleReset">
<template #icon>
<NIcon><icon-park-outline:refresh /></NIcon>
</template>
重置
</NButton>
</NSpace>
</n-form-item>
</n-grid-item>
</n-grid>
</n-form>
</div>
<!-- 表格头部操作栏 -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<div class="flex items-center gap-4">
<NButton
v-button="PERMISSIONS.OPER_LOG.DELETE"
type="error"
:disabled="selectedRows.length === 0"
class="px-3 flex items-center"
@click="handleBatchDelete"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:delete />
</NIcon>
</template>
删除
</NButton>
<NButton
v-button="PERMISSIONS.OPER_LOG.DELETE"
type="warning"
class="px-3 flex items-center"
@click="handleClearAll"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:delete />
</NIcon>
</template>
清空
</NButton>
</div>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span> {{ pagination.itemCount }} </span>
<NButton text @click="getOperLogList">
<template #icon>
<NIcon><icon-park-outline:refresh /></NIcon>
</template>
</NButton>
</div>
</div>
<!-- 表格内容 -->
<div class="table-wrapper flex-1 overflow-auto pt-4">
<!-- 数据表格 -->
<n-data-table
v-if="tableData.length > 0 || loading"
:columns="columns"
:data="tableData"
:loading="loading"
:row-key="(row: OperLogVo) => row.operId"
:bordered="false"
:single-line="false"
size="large"
class="custom-table"
@update:checked-row-keys="handleRowSelectionChange"
/>
<!-- 空状态 -->
<CoiEmpty
v-else
:type="getEmptyType()"
:title="getEmptyTitle()"
:description="getEmptyDescription()"
:show-action="true"
:action-text="getEmptyActionText()"
size="medium"
@action="handleEmptyAction"
>
<template #action>
<NButton
type="primary"
size="medium"
round
class="coi-empty__action-btn"
@click="handleEmptyAction"
>
<template #icon>
<NIcon>
<icon-park-outline:refresh />
</NIcon>
</template>
{{ getEmptyActionText() }}
</NButton>
</template>
</CoiEmpty>
</div>
<!-- 分页器 -->
<div v-if="tableData.length > 0" class="flex items-center px-4 py-2 border-t border-gray-100">
<div class="text-sm text-gray-500 mr-4">
{{ pagination.itemCount }}
</div>
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:show-size-picker="true"
:page-sizes="[10, 20, 50, 100]"
show-quick-jumper
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<!-- 操作日志详情弹框 -->
<CoiDialog
ref="detailDialogRef"
title="操作日志详情"
:width="900"
height="auto"
cancel-text="关闭"
:show-confirm="false"
@coi-cancel="handleCloseDetail"
>
<template #content>
<div class="p-3">
<n-descriptions
v-if="currentLogDetail"
:column="2"
bordered
label-placement="left"
class="oper-log-detail"
>
<n-descriptions-item label="方法名称" :span="2">
<span class="method-name">{{ currentLogDetail.methodName }}</span>
</n-descriptions-item>
<n-descriptions-item label="消耗时间[毫秒]">
{{ currentLogDetail.costTime || '-' }}
</n-descriptions-item>
<n-descriptions-item label="操作状态">
<NTag :type="currentLogDetail.operStatus === '0' ? 'success' : 'error'" size="small">
{{ currentLogDetail.operStatus === '0' ? '操作成功' : '操作失败' }}
</NTag>
</n-descriptions-item>
<n-descriptions-item v-if="currentLogDetail.operParam" label="请求参数" :span="2">
<div class="json-container">
<pre class="json-content">{{ formatJson(currentLogDetail.operParam) }}</pre>
</div>
</n-descriptions-item>
<n-descriptions-item v-if="currentLogDetail.jsonResult" label="返回数据" :span="2">
<div class="json-container">
<pre class="json-content">{{ formatJson(currentLogDetail.jsonResult) }}</pre>
</div>
</n-descriptions-item>
</n-descriptions>
</div>
</template>
</CoiDialog>
</div>
</template>
<script setup lang="ts">
import { h, nextTick, onMounted, ref } from 'vue'
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NSpace, NTag, NTooltip } from 'naive-ui'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlinePreviewOpen from '~icons/icon-park-outline/preview-open'
import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import {
batchDeleteOperLog,
clearOperLog,
deleteOperLog,
getOperLogDetailById,
getOperLogListPage,
} from '@/service/api/system/operlog'
import type { OperLogSearchForm, OperLogVo } from '@/service/api/system/operlog'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { PERMISSIONS } from '@/constants/permissions'
import { usePermission } from '@/hooks/usePermission'
//
const { hasButton } = usePermission()
//
const loading = ref(false)
const tableData = ref<OperLogVo[]>([])
const searchFormRef = ref<FormInst | null>(null)
const selectedRows = ref<OperLogVo[]>([])
const currentLogDetail = ref<OperLogVo | null>(null)
//
const detailDialogRef = ref()
//
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
})
//
const searchForm = ref<OperLogSearchForm>({})
// JSON
function formatJson(jsonStr: string) {
try {
const obj = JSON.parse(jsonStr)
return JSON.stringify(obj, null, 2)
}
catch {
return jsonStr
}
}
//
const columns: DataTableColumns<OperLogVo> = [
{
type: 'selection',
width: 50,
},
{
title: '序号',
key: 'index',
width: 70,
align: 'center',
render: (_, index) => {
return (pagination.value.page - 1) * pagination.value.pageSize + index + 1
},
},
{
title: '操作名称',
key: 'operName',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.operName)
},
},
{
title: '操作类型',
key: 'operType',
width: 100,
align: 'center',
render: (row) => {
return h(NTag, {
type: 'primary',
size: 'small',
}, {
default: () => row.operType,
})
},
},
{
title: '操作人员[登录名/用户名]',
key: 'operMan',
width: 150,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.operMan)
},
},
{
title: '系统类型',
key: 'systemType',
width: 100,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.systemType || '-')
},
},
{
title: '请求方式',
key: 'requestMethod',
width: 90,
align: 'center',
render: (row) => {
return h(NTag, {
type: 'primary',
size: 'small',
}, {
default: () => row.requestMethod || '-',
})
},
},
{
title: '请求URL',
key: 'operUrl',
width: 200,
align: 'left',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', {
class: 'text-gray-600 font-mono text-sm',
title: row.operUrl,
}, row.operUrl || '-')
},
},
{
title: '操作IP',
key: 'operIp',
width: 130,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.operIp)
},
},
{
title: '操作地点',
key: 'operLocation',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-500' }, row.operLocation || '-')
},
},
{
title: '操作状态',
key: 'operStatus',
width: 100,
align: 'center',
render: (row) => {
const isSuccess = row.operStatus === '0'
return h(NTag, {
type: isSuccess ? 'success' : 'error',
size: 'small',
}, {
default: () => isSuccess ? '操作成功' : '操作失败',
})
},
},
{
title: '操作时间',
key: 'operTime',
width: 180,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-500 text-sm' }, row.operTime)
},
},
{
title: '消耗时间',
key: 'costTime',
width: 100,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-500 text-sm' }, `${row.costTime || '0'}ms`)
},
},
{
title: '操作',
key: 'actions',
width: 120,
align: 'center',
fixed: 'right',
render: (row) => {
const buttons = []
//
buttons.push(h(NTooltip, {
trigger: 'hover',
}, {
default: () => '查看详情',
trigger: () => h(NButton, {
type: 'primary',
size: 'medium',
circle: true,
class: 'action-btn action-btn-info',
onClick: () => handleViewDetail(row),
}, {
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlinePreviewOpen) }),
}),
}))
//
if (hasButton(PERMISSIONS.OPER_LOG.DELETE)) {
buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleDelete(row.operId),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => '确定删除此操作记录吗?',
trigger: () => h(NTooltip, {
trigger: 'hover',
}, {
default: () => '删除',
trigger: () => h(NButton, {
type: 'error',
size: 'medium',
circle: true,
class: 'action-btn action-btn-delete',
}, {
icon: () => h(NIcon, { size: 18 }, { default: () => h(IconParkOutlineDelete) }),
}),
}),
}))
}
return h('div', { class: 'flex items-center justify-center gap-2' }, buttons)
},
},
]
//
async function getOperLogList() {
loading.value = true
try {
//
const { timeRange, ...otherParams } = searchForm.value
// null
const filteredParams = Object.entries(otherParams).reduce((acc, [key, value]) => {
if (value !== null && value !== undefined && value !== '') {
acc[key] = value
}
return acc
}, {} as Record<string, any>)
const params = {
pageNo: pagination.value.page,
pageSize: pagination.value.pageSize,
...filteredParams,
}
// beginTimeendTime
if (timeRange && timeRange.length === 2) {
params.beginTime = new Date(timeRange[0]).toISOString().slice(0, 19).replace('T', ' ')
params.endTime = new Date(timeRange[1]).toISOString().slice(0, 19).replace('T', ' ')
}
const { isSuccess, data } = await getOperLogListPage(params)
if (isSuccess && data) {
tableData.value = data.records || []
pagination.value.itemCount = data.total || 0
}
else {
console.warn('获取操作日志列表失败,可能是权限或网络问题')
coiMsgError('获取操作日志列表失败,请检查网络连接或联系管理员')
tableData.value = []
pagination.value.itemCount = 0
}
}
catch (error) {
console.error('获取操作日志列表失败:', error)
coiMsgError('获取操作日志列表失败,请检查网络连接')
tableData.value = []
pagination.value.itemCount = 0
}
finally {
loading.value = false
}
}
//
function handlePageChange(page: number) {
pagination.value.page = page
getOperLogList()
}
function handlePageSizeChange(pageSize: number) {
pagination.value.pageSize = pageSize
pagination.value.page = 1
getOperLogList()
}
//
function handleSearch() {
pagination.value.page = 1
getOperLogList()
}
//
async function handleReset() {
//
searchForm.value = {
operName: '',
operMan: '',
operType: null,
operStatus: null,
operIp: '',
beginTime: '',
endTime: '',
timeRange: null,
}
// 使 nextTick DOM
await nextTick()
//
if (searchFormRef.value) {
searchFormRef.value.restoreValidation()
}
//
pagination.value.page = 1
//
getOperLogList()
}
//
function handleRowSelectionChange(rowKeys: number[]) {
selectedRows.value = tableData.value.filter(row => rowKeys.includes(row.operId))
}
//
async function handleViewDetail(log: OperLogVo) {
try {
const { isSuccess, data } = await getOperLogDetailById(log.operId)
if (isSuccess && data) {
currentLogDetail.value = data
detailDialogRef.value?.coiOpen()
}
else {
coiMsgError('获取操作日志详情失败')
}
}
catch (error) {
console.error('获取操作日志详情失败:', error)
coiMsgError('获取操作日志详情失败')
}
}
//
function handleCloseDetail() {
detailDialogRef.value?.coiClose()
currentLogDetail.value = null
}
//
async function handleDelete(operId: number) {
try {
const { isSuccess } = await deleteOperLog(operId)
if (isSuccess) {
coiMsgSuccess('删除成功')
await getOperLogList()
}
else {
coiMsgError('删除失败')
}
}
catch (error) {
console.error('删除失败:', error)
coiMsgError('删除失败')
}
}
//
async function handleBatchDelete() {
if (selectedRows.value.length === 0) {
coiMsgWarning('请先选择要删除的操作记录')
return
}
try {
//
await coiMsgBox(`确定要删除选中的 ${selectedRows.value.length} 条操作记录吗?`, '批量删除确认')
}
catch {
//
return
}
//
try {
const operIds = selectedRows.value.map(log => log.operId)
const { isSuccess } = await batchDeleteOperLog(operIds)
if (isSuccess) {
coiMsgSuccess('批量删除成功')
selectedRows.value = []
await getOperLogList()
}
else {
coiMsgError('批量删除失败')
}
}
catch (error) {
console.error('批量删除失败:', error)
coiMsgError('批量删除失败')
}
}
//
async function handleClearAll() {
try {
//
await coiMsgBox('确定要清空所有操作日志吗?此操作不可恢复!', '清空确认')
}
catch {
//
return
}
//
try {
const { isSuccess } = await clearOperLog()
if (isSuccess) {
coiMsgSuccess('清空成功')
selectedRows.value = []
await getOperLogList()
}
else {
coiMsgError('清空失败')
}
}
catch (error) {
console.error('清空失败:', error)
coiMsgError('清空失败')
}
}
//
function hasSearchConditions() {
return Object.values(searchForm.value).some((value) => {
if (value === null || value === undefined || value === '') {
return false
}
if (Array.isArray(value) && value.length === 0) {
return false
}
return true
})
}
//
function getEmptyType() {
if (hasSearchConditions()) {
return 'search'
}
return 'default'
}
//
function getEmptyTitle() {
if (hasSearchConditions()) {
return '搜索无结果'
}
return '暂无操作日志'
}
//
function getEmptyDescription() {
if (hasSearchConditions()) {
return '未找到符合搜索条件的操作记录,请尝试调整搜索条件或重置筛选'
}
return '当前还没有操作日志数据'
}
//
function getEmptyActionText() {
if (hasSearchConditions()) {
return '重置筛选'
}
return '刷新数据'
}
//
function handleEmptyAction() {
if (hasSearchConditions()) {
handleReset()
}
else {
getOperLogList()
}
}
//
onMounted(() => {
getOperLogList()
})
</script>
<style scoped>
.operlog-management {
height: 100vh;
overflow: hidden;
}
.custom-table :deep(.n-data-table-td) {
padding: 8px 12px;
}
.custom-table :deep(.n-data-table-th) {
padding: 10px 12px;
background-color: #fafafa;
font-weight: normal;
color: #262626;
}
.custom-table :deep(.n-data-table-tr:hover .n-data-table-td) {
background-color: #f8faff;
}
.search-form :deep(.n-form-item-label) {
font-weight: 500;
color: #262626;
}
/* CoiEmpty按钮样式 */
.coi-empty__action-btn {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border: none;
}
.coi-empty__action-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.6s ease;
}
.coi-empty__action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.coi-empty__action-btn:hover::before {
left: 100%;
}
/* 操作按钮样式 */
.action-btn {
width: 36px !important;
height: 36px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 50% !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08) !important;
border: none !important;
position: relative !important;
overflow: hidden !important;
}
.action-btn:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15) !important;
}
.action-btn:active {
transform: translateY(0) !important;
transition: all 0.1s !important;
}
/* 删除按钮 */
.action-btn-delete {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%) !important;
color: white !important;
}
.action-btn-delete:hover {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%) !important;
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4) !important;
}
/* 信息按钮 */
.action-btn-info {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%) !important;
color: white !important;
}
.action-btn-info:hover {
background: linear-gradient(135deg, #0891b2 0%, #0e7490 100%) !important;
box-shadow: 0 4px 16px rgba(6, 182, 212, 0.4) !important;
}
/* 图标居中对齐 */
.action-btn .n-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 !important;
}
/* 移除默认边框和背景 */
.action-btn.n-button {
border: none !important;
}
.action-btn.n-button:focus {
outline: none !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) !important;
}
/* 操作日志详情样式 */
.oper-log-detail :deep(.n-descriptions-item-label) {
font-weight: 600;
color: #374151;
background-color: #f9fafb;
width: 120px;
white-space: nowrap;
}
.oper-log-detail :deep(.n-descriptions-item-content) {
color: #6b7280;
}
/* 方法名称样式 */
.method-name {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
color: #7c3aed;
font-weight: 500;
}
/* URL 和方法名样式 */
.url-text {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
background-color: #f3f4f6;
padding: 4px 8px;
border-radius: 4px;
color: #059669;
word-break: break-all;
}
.method-text {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
background-color: #f3f4f6;
padding: 4px 8px;
border-radius: 4px;
color: #7c3aed;
word-break: break-all;
}
/* JSON 容器样式 */
.json-container {
max-height: 300px;
overflow: auto;
background-color: #1f2937;
border-radius: 6px;
padding: 12px;
margin: 4px 0;
}
.json-content {
color: #f9fafb;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
line-height: 1.5;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
/* 错误消息样式 */
.error-msg {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px;
border-radius: 6px;
font-size: 14px;
line-height: 1.5;
word-break: break-all;
}
</style>