操作日志

This commit is contained in:
18631081161 2026-01-14 23:36:09 +08:00
parent adef288429
commit ece16711f5
9 changed files with 462 additions and 2 deletions

View File

@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MilitaryTrainingManagement.Services.Interfaces;
namespace MilitaryTrainingManagement.Controllers;
/// <summary>
/// 审计日志控制器
/// </summary>
[Authorize]
public class AuditLogsController : BaseApiController
{
private readonly IAuditService _auditService;
public AuditLogsController(IAuditService auditService)
{
_auditService = auditService;
}
/// <summary>
/// 获取审计日志列表(分页)
/// </summary>
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] AuditLogQueryParameters parameters)
{
var logs = await _auditService.GetLogsAsync(parameters);
var totalCount = await _auditService.GetLogCountAsync(parameters);
var pageSize = parameters.PageSize;
var pageNumber = parameters.PageNumber;
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return Ok(new
{
items = logs,
totalCount,
pageNumber,
pageSize,
totalPages
});
}
/// <summary>
/// 根据ID获取审计日志详情
/// </summary>
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var log = await _auditService.GetLogByIdAsync(id);
if (log == null)
{
return NotFound(new { message = "审计日志不存在" });
}
return Ok(log);
}
}

View File

@ -47,11 +47,11 @@ public class AuditInterceptor : SaveChangesInterceptor
if (httpContext != null)
{
var userIdClaim = httpContext.User.FindFirst("UserId")?.Value;
var userIdClaim = httpContext.User.FindFirst("userId")?.Value;
if (int.TryParse(userIdClaim, out var parsedUserId))
userId = parsedUserId;
var unitIdClaim = httpContext.User.FindFirst("OrganizationalUnitId")?.Value;
var unitIdClaim = httpContext.User.FindFirst("unitId")?.Value;
if (int.TryParse(unitIdClaim, out var parsedUnitId))
organizationalUnitId = parsedUnitId;

View File

@ -205,6 +205,45 @@ public class AuditService : IAuditService
.ToListAsync();
}
public async Task<int> GetLogCountAsync(AuditLogQueryParameters parameters)
{
var query = _context.AuditLogs.AsQueryable();
if (!string.IsNullOrEmpty(parameters.EntityType))
query = query.Where(l => l.EntityType == parameters.EntityType);
if (parameters.EntityId.HasValue)
query = query.Where(l => l.EntityId == parameters.EntityId.Value);
if (!string.IsNullOrEmpty(parameters.Action))
query = query.Where(l => l.Action == parameters.Action);
if (parameters.UserId.HasValue)
query = query.Where(l => l.UserId == parameters.UserId.Value);
if (parameters.OrganizationalUnitId.HasValue)
query = query.Where(l => l.OrganizationalUnitId == parameters.OrganizationalUnitId.Value);
if (parameters.FromDate.HasValue)
query = query.Where(l => l.Timestamp >= parameters.FromDate.Value);
if (parameters.ToDate.HasValue)
query = query.Where(l => l.Timestamp <= parameters.ToDate.Value);
if (parameters.IsSuccess.HasValue)
query = query.Where(l => l.IsSuccess == parameters.IsSuccess.Value);
return await query.CountAsync();
}
public async Task<AuditLog?> GetLogByIdAsync(int id)
{
return await _context.AuditLogs
.Include(l => l.User)
.Include(l => l.OrganizationalUnit)
.FirstOrDefaultAsync(l => l.Id == id);
}
public async Task<IEnumerable<AuditLog>> GetLogsAsync(string? entityType = null, int? entityId = null, DateTime? fromDate = null, DateTime? toDate = null)
{
return await GetLogsAsync(new AuditLogQueryParameters

View File

@ -47,6 +47,16 @@ public interface IAuditService
/// </summary>
Task<IEnumerable<AuditLog>> GetLogsAsync(AuditLogQueryParameters parameters);
/// <summary>
/// 获取审计日志总数
/// </summary>
Task<int> GetLogCountAsync(AuditLogQueryParameters parameters);
/// <summary>
/// 根据ID获取审计日志
/// </summary>
Task<AuditLog?> GetLogByIdAsync(int id);
/// <summary>
/// 获取审计日志(简化版本)
/// </summary>

View File

@ -0,0 +1,53 @@
import apiClient from './client'
export interface AuditLog {
id: number
entityType: string
entityId: number
action: string
description?: string
oldValues?: string
newValues?: string
changedFields?: string
userId?: number
userName?: string
organizationalUnitId?: number
organizationalUnitName?: string
timestamp: string
ipAddress?: string
userAgent?: string
requestPath?: string
isSuccess: boolean
errorMessage?: string
}
export interface AuditLogQueryParams {
entityType?: string
action?: string
userId?: number
organizationalUnitId?: number
fromDate?: string
toDate?: string
pageNumber?: number
pageSize?: number
}
export interface PagedResult<T> {
items: T[]
totalCount: number
pageNumber: number
pageSize: number
totalPages: number
}
export const auditLogsApi = {
async getAll(params?: AuditLogQueryParams): Promise<PagedResult<AuditLog>> {
const response = await apiClient.get<PagedResult<AuditLog>>('/auditlogs', { params })
return response.data
},
async getById(id: number): Promise<AuditLog> {
const response = await apiClient.get<AuditLog>(`/auditlogs/${id}`)
return response.data
}
}

View File

@ -6,4 +6,5 @@ export { reportsApi } from './reports'
export { personnelApi } from './personnel'
export { approvalsApi } from './approvals'
export { statsApi } from './stats'
export { auditLogsApi } from './auditLogs'
export { default as apiClient } from './client'

View File

@ -53,6 +53,11 @@
<el-icon><Checked /></el-icon>
<span>审批管理</span>
</el-menu-item>
<el-menu-item v-if="authStore.hasPermission(1)" index="/audit-logs">
<el-icon><Document /></el-icon>
<span>操作记录</span>
</el-menu-item>
</el-menu>
</el-aside>
@ -97,6 +102,7 @@ import {
DataAnalysis,
User,
Checked,
Document,
SwitchButton
} from '@element-plus/icons-vue'

View File

@ -100,6 +100,12 @@ const routes: RouteRecordRaw[] = [
name: 'ApprovalDetail',
component: () => import('@/views/approvals/ApprovalDetail.vue'),
meta: { title: '审批详情' }
},
{
path: 'audit-logs',
name: 'AuditLogs',
component: () => import('@/views/AuditLogs.vue'),
meta: { title: '操作记录', minLevel: 1 }
}
]
},

View File

@ -0,0 +1,289 @@
<template>
<div class="audit-logs">
<el-card>
<template #header>
<div class="card-header">
<span>操作记录</span>
</div>
</template>
<!-- 筛选条件 -->
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="操作类型">
<el-select v-model="filters.action" placeholder="全部" clearable style="width: 150px">
<el-option label="创建" value="Create" />
<el-option label="更新" value="Update" />
<el-option label="删除" value="Delete" />
<el-option label="审批" value="Approve" />
<el-option label="拒绝" value="Reject" />
</el-select>
</el-form-item>
<el-form-item label="数据类型">
<el-select v-model="filters.entityType" placeholder="全部" clearable style="width: 150px">
<el-option label="物资配额" value="MaterialAllocation" />
<el-option label="配额分配" value="AllocationDistribution" />
<el-option label="用户账户" value="UserAccount" />
<el-option label="组织单位" value="OrganizationalUnit" />
<el-option label="人员信息" value="Personnel" />
<el-option label="审批请求" value="ApprovalRequest" />
<el-option label="物资类别" value="MaterialCategory" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 日志列表 -->
<el-table :data="logs" v-loading="loading" stripe>
<el-table-column prop="timestamp" label="时间" width="160">
<template #default="{ row }">
{{ formatDate(row.timestamp) }}
</template>
</el-table-column>
<el-table-column label="操作人" width="120">
<template #default="{ row }">
{{ row.user?.displayName || '系统' }}
</template>
</el-table-column>
<el-table-column label="所属单位" width="150" show-overflow-tooltip>
<template #default="{ row }">
{{ row.organizationalUnit?.name || '-' }}
</template>
</el-table-column>
<el-table-column prop="action" label="操作" width="100">
<template #default="{ row }">
<el-tag :type="getActionTagType(row.action)" size="small">
{{ getActionName(row.action) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="entityType" label="数据类型" width="120">
<template #default="{ row }">
{{ getEntityTypeName(row.entityType) }}
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="ipAddress" label="IP地址" width="130" />
<el-table-column prop="isSuccess" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.isSuccess ? 'success' : 'danger'" size="small">
{{ row.isSuccess ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" text size="small" @click="handleViewDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.pageNumber"
v-model:page-size="pagination.pageSize"
:total="pagination.totalCount"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="handleSearch"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
<!-- 详情对话框 -->
<el-dialog v-model="showDetailDialog" title="操作详情" width="700px">
<el-descriptions v-if="selectedLog" :column="2" border>
<el-descriptions-item label="时间">{{ formatDate(selectedLog.timestamp) }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ selectedLog.user?.displayName || '系统' }}</el-descriptions-item>
<el-descriptions-item label="所属单位">{{ selectedLog.organizationalUnit?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="操作类型">
<el-tag :type="getActionTagType(selectedLog.action)" size="small">
{{ getActionName(selectedLog.action) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="数据类型">{{ getEntityTypeName(selectedLog.entityType) }}</el-descriptions-item>
<el-descriptions-item label="数据ID">{{ selectedLog.entityId }}</el-descriptions-item>
<el-descriptions-item label="IP地址">{{ selectedLog.ipAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="selectedLog.isSuccess ? 'success' : 'danger'" size="small">
{{ selectedLog.isSuccess ? '成功' : '失败' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ selectedLog.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="请求路径" :span="2">{{ selectedLog.requestPath || '-' }}</el-descriptions-item>
<el-descriptions-item label="User Agent" :span="2">{{ selectedLog.userAgent || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="selectedLog.oldValues" label="原始值" :span="2">
<pre class="json-content">{{ formatJson(selectedLog.oldValues) }}</pre>
</el-descriptions-item>
<el-descriptions-item v-if="selectedLog.newValues" label="新值" :span="2">
<pre class="json-content">{{ formatJson(selectedLog.newValues) }}</pre>
</el-descriptions-item>
<el-descriptions-item v-if="selectedLog.changedFields" label="变更字段" :span="2">
<pre class="json-content">{{ formatJson(selectedLog.changedFields) }}</pre>
</el-descriptions-item>
<el-descriptions-item v-if="selectedLog.errorMessage" label="错误信息" :span="2">
<el-text type="danger">{{ selectedLog.errorMessage }}</el-text>
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="showDetailDialog = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { auditLogsApi } from '@/api'
import type { AuditLog } from '@/api/auditLogs'
const loading = ref(false)
const logs = ref<AuditLog[]>([])
const showDetailDialog = ref(false)
const selectedLog = ref<AuditLog | null>(null)
const dateRange = ref<[Date, Date] | null>(null)
const filters = reactive({
action: '',
entityType: ''
})
const pagination = reactive({
pageNumber: 1,
pageSize: 20,
totalCount: 0
})
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString('zh-CN')
}
function getActionName(action: string): string {
const actionMap: Record<string, string> = {
'Create': '创建',
'Update': '更新',
'Delete': '删除',
'Approve': '审批',
'Reject': '拒绝',
'Submit': '提交',
'Review': '审核'
}
return actionMap[action] || action
}
function getActionTagType(action: string): string {
const typeMap: Record<string, string> = {
'Create': 'success',
'Update': 'warning',
'Delete': 'danger',
'Approve': 'success',
'Reject': 'danger',
'Submit': 'primary',
'Review': 'info'
}
return typeMap[action] || 'info'
}
function getEntityTypeName(entityType: string): string {
const nameMap: Record<string, string> = {
'MaterialAllocation': '物资配额',
'AllocationDistribution': '配额分配',
'UserAccount': '用户账户',
'OrganizationalUnit': '组织单位',
'Personnel': '人员信息',
'ApprovalRequest': '审批请求',
'MaterialCategory': '物资类别'
}
return nameMap[entityType] || entityType
}
function formatJson(jsonStr: string): string {
try {
return JSON.stringify(JSON.parse(jsonStr), null, 2)
} catch {
return jsonStr
}
}
async function loadLogs() {
loading.value = true
try {
const params: any = {
pageNumber: pagination.pageNumber,
pageSize: pagination.pageSize
}
if (filters.action) params.action = filters.action
if (filters.entityType) params.entityType = filters.entityType
if (dateRange.value) {
params.fromDate = dateRange.value[0].toISOString()
params.toDate = dateRange.value[1].toISOString()
}
const result = await auditLogsApi.getAll(params)
logs.value = result.items
pagination.totalCount = result.totalCount
} catch {
ElMessage.error('加载操作记录失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.pageNumber = 1
loadLogs()
}
function handleReset() {
filters.action = ''
filters.entityType = ''
dateRange.value = null
pagination.pageNumber = 1
loadLogs()
}
function handleViewDetail(log: AuditLog) {
selectedLog.value = log
showDetailDialog.value = true
}
onMounted(() => {
loadLogs()
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-form {
margin-bottom: 16px;
}
.json-content {
background-color: #f5f7fa;
padding: 12px;
border-radius: 4px;
font-size: 12px;
max-height: 300px;
overflow: auto;
}
</style>