修改普通用户的数据类型转换在数据集管理看不见的问题
This commit is contained in:
58
frontend/src/api/modules/operation-log.ts
Normal file
58
frontend/src/api/modules/operation-log.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { get } from '../request'
|
||||
|
||||
export interface OperationLog {
|
||||
id: string
|
||||
user_id?: string
|
||||
username?: string
|
||||
module?: string
|
||||
action?: string
|
||||
target_type?: string
|
||||
target_id?: string
|
||||
target_name?: string
|
||||
status: string
|
||||
error_message?: string
|
||||
error_type?: string
|
||||
error_traceback?: string
|
||||
func_name?: string
|
||||
detail?: string
|
||||
client_ip?: string
|
||||
request_method?: string
|
||||
request_path?: string
|
||||
trace_id?: string
|
||||
duration_ms?: number
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
export interface OperationLogQuery {
|
||||
user_id?: string
|
||||
module?: string
|
||||
action?: string
|
||||
status?: string
|
||||
keyword?: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface OperationLogStats {
|
||||
total: number
|
||||
success: number
|
||||
failure: number
|
||||
failure_rate: number
|
||||
module_failures: { module: string; count: number }[]
|
||||
error_types: { type: string; count: number }[]
|
||||
recent_errors: OperationLog[]
|
||||
}
|
||||
|
||||
/** 操作日志查询 */
|
||||
export const getOperationLogs = (query: OperationLogQuery = {}) =>
|
||||
get<{ items: OperationLog[]; total: number }>('/system/operation-logs', query)
|
||||
|
||||
/** 操作日志统计 */
|
||||
export const getOperationLogStats = (params?: { start_time?: string; end_time?: string }) =>
|
||||
get<OperationLogStats>('/system/operation-logs/stats', params)
|
||||
|
||||
/** 获取操作日志中出现的模块列表 */
|
||||
export const getOperationLogModules = () =>
|
||||
get<{ value: string; label: string }[]>('/system/operation-logs/modules')
|
||||
@@ -81,6 +81,7 @@ const menuGroups: MenuGroup[] = [
|
||||
{ key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' },
|
||||
{ key: 'resource-acl', label: '资源授权', icon: 'fa-key', to: '/resource-acl', permission: 'user-settings' },
|
||||
{ key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', permission: 'user-settings' },
|
||||
{ key: 'operation-logs', label: '操作日志', icon: 'fa-list', to: '/operation-logs', permission: 'user-settings' },
|
||||
{ key: 'approval-templates', label: '审批模板', icon: 'fa-list-alt', to: '/approval-templates', permission: 'user-settings' },
|
||||
{ key: 'approval-instances', label: '审批中心', icon: 'fa-check-square', to: '/approval-instances', permission: 'user-settings' },
|
||||
],
|
||||
|
||||
@@ -62,6 +62,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/audit/AuditLogView.vue'),
|
||||
meta: { title: '审计日志', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'operation-logs',
|
||||
name: 'operation-logs',
|
||||
component: () => import('@/views/audit/OperationLogView.vue'),
|
||||
meta: { title: '操作日志', permission: 'user-settings' },
|
||||
},
|
||||
{
|
||||
path: 'approval-templates',
|
||||
name: 'approval-templates',
|
||||
@@ -355,6 +361,7 @@ const permissionBySegment: Record<string, PermissionCode> = {
|
||||
tenants: 'user-settings',
|
||||
projects: 'user-settings',
|
||||
'audit-logs': 'user-settings',
|
||||
'operation-logs': 'user-settings',
|
||||
'approval-templates': 'user-settings',
|
||||
'approval-instances': 'user-settings',
|
||||
'resource-acl': 'user-settings',
|
||||
|
||||
437
frontend/src/views/audit/OperationLogView.vue
Normal file
437
frontend/src/views/audit/OperationLogView.vue
Normal file
@@ -0,0 +1,437 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getOperationLogs,
|
||||
getOperationLogStats,
|
||||
type OperationLog,
|
||||
type OperationLogQuery,
|
||||
type OperationLogStats,
|
||||
} from '@/api/modules/operation-log'
|
||||
|
||||
const loading = ref(false)
|
||||
const statsLoading = ref(false)
|
||||
const logs = ref<OperationLog[]>([])
|
||||
const total = ref(0)
|
||||
const stats = ref<OperationLogStats | null>(null)
|
||||
|
||||
// 默认筛选:只看失败
|
||||
const query = reactive<OperationLogQuery>({
|
||||
user_id: '',
|
||||
module: '',
|
||||
action: '',
|
||||
status: 'failure',
|
||||
keyword: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const timeRange = ref<[string, string] | null>(null)
|
||||
|
||||
// 模块选项
|
||||
const moduleOptions = [
|
||||
{ value: 'fine-tune', label: '模型训练' },
|
||||
{ value: 'model-eval', label: '模型评测' },
|
||||
{ value: 'model-inference', label: '模型推理' },
|
||||
{ value: 'model-manage', label: '模型管理' },
|
||||
{ value: 'dataset', label: '数据集' },
|
||||
{ value: 'data-process', label: '数据处理' },
|
||||
{ value: 'data-convert', label: '数据类型转换' },
|
||||
{ value: 'compute', label: '算力节点' },
|
||||
{ value: 'system', label: '系统' },
|
||||
]
|
||||
|
||||
// 动作选项
|
||||
const actionOptions = [
|
||||
{ value: 'create', label: '创建' },
|
||||
{ value: 'start', label: '启动' },
|
||||
{ value: 'stop', label: '停止' },
|
||||
{ value: 'delete', label: '删除' },
|
||||
{ value: 'upload', label: '上传' },
|
||||
{ value: 'convert', label: '转换' },
|
||||
{ value: 'merge', label: '合并' },
|
||||
{ value: 'download', label: '导出' },
|
||||
{ value: 'import', label: '导入' },
|
||||
{ value: 'publish', label: '发布' },
|
||||
{ value: 'request', label: '系统请求' },
|
||||
]
|
||||
|
||||
// 状态选项
|
||||
const statusOptions = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'failure', label: '仅失败' },
|
||||
{ value: 'success', label: '仅成功' },
|
||||
]
|
||||
|
||||
// 详情弹窗
|
||||
const detailVisible = ref(false)
|
||||
const detailLog = ref<OperationLog | null>(null)
|
||||
|
||||
// 是否只看失败
|
||||
const onlyFailures = computed(() => query.status === 'failure')
|
||||
|
||||
function applyTimeRange() {
|
||||
if (timeRange.value && timeRange.value.length === 2) {
|
||||
query.start_time = timeRange.value[0]
|
||||
query.end_time = timeRange.value[1]
|
||||
} else {
|
||||
query.start_time = ''
|
||||
query.end_time = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: OperationLogQuery = { ...query }
|
||||
Object.keys(params).forEach((k) => {
|
||||
if (params[k as keyof OperationLogQuery] === '' || params[k as keyof OperationLogQuery] === null) {
|
||||
delete params[k as keyof OperationLogQuery]
|
||||
}
|
||||
})
|
||||
const res = await getOperationLogs(params)
|
||||
logs.value = res.items
|
||||
total.value = res.total
|
||||
} catch {
|
||||
ElMessage.error('加载操作日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
statsLoading.value = true
|
||||
try {
|
||||
const params: { start_time?: string; end_time?: string } = {}
|
||||
if (query.start_time) params.start_time = query.start_time
|
||||
if (query.end_time) params.end_time = query.end_time
|
||||
stats.value = await getOperationLogStats(params)
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.offset = 0
|
||||
load()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
query.offset = (page - 1) * (query.limit || 50)
|
||||
load()
|
||||
}
|
||||
|
||||
function showDetail(row: OperationLog) {
|
||||
detailLog.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function statusTagType(status: string) {
|
||||
return status === 'success' ? 'success' : 'danger'
|
||||
}
|
||||
|
||||
function moduleLabel(module?: string) {
|
||||
return moduleOptions.find((m) => m.value === module)?.label || module || '-'
|
||||
}
|
||||
|
||||
function actionLabel(action?: string) {
|
||||
return actionOptions.find((a) => a.value === action)?.label || action || '-'
|
||||
}
|
||||
|
||||
function toggleOnlyFailures() {
|
||||
query.status = onlyFailures.value ? '' : 'failure'
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">系统操作日志</h2>
|
||||
<el-button :type="onlyFailures ? 'danger' : 'default'" @click="toggleOnlyFailures">
|
||||
{{ onlyFailures ? '只看失败 ✅' : '显示全部' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="12" class="stats-row" v-loading="statsLoading">
|
||||
<el-col :span="4">
|
||||
<el-card class="stat-card" shadow="hover">
|
||||
<div class="stat-value">{{ stats?.total ?? '-' }}</div>
|
||||
<div class="stat-label">总操作数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card class="stat-card" shadow="hover">
|
||||
<div class="stat-value" style="color: #67c23a">{{ stats?.success ?? '-' }}</div>
|
||||
<div class="stat-label">成功</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card class="stat-card" shadow="hover">
|
||||
<div class="stat-value" style="color: #f56c6c">{{ stats?.failure ?? '-' }}</div>
|
||||
<div class="stat-label">失败</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card class="stat-card" shadow="hover">
|
||||
<div class="stat-value" :style="{ color: (stats?.failure_rate ?? 0) > 5 ? '#f56c6c' : '#909399' }">
|
||||
{{ stats?.failure_rate ?? '-' }}%
|
||||
</div>
|
||||
<div class="stat-label">失败率</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card class="stat-card" shadow="hover">
|
||||
<div class="stat-label" style="margin-bottom: 4px">各模块失败分布</div>
|
||||
<div class="stat-modules">
|
||||
<el-tag
|
||||
v-for="m in stats?.module_failures || []"
|
||||
:key="m.module"
|
||||
type="danger"
|
||||
size="small"
|
||||
style="margin-right: 4px; margin-bottom: 4px"
|
||||
>
|
||||
{{ moduleOptions.find((opt) => opt.value === m.module)?.label || m.module }}: {{ m.count }}
|
||||
</el-tag>
|
||||
<span v-if="!stats?.module_failures?.length" style="color: #c0c4cc">暂无失败</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<el-card class="filter-card">
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" placeholder="全部状态" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模块">
|
||||
<el-select v-model="query.module" placeholder="全部模块" clearable style="width: 150px" @change="handleSearch">
|
||||
<el-option v-for="m in moduleOptions" :key="m.value" :label="m.label" :value="m.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="动作">
|
||||
<el-select v-model="query.action" placeholder="全部动作" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option v-for="a in actionOptions" :key="a.value" :label="a.label" :value="a.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="报错关键字">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="搜索报错信息/异常类型"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户">
|
||||
<el-input v-model="query.user_id" placeholder="用户ID/用户名" clearable style="width: 140px" @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="timeRange"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
clearable
|
||||
style="width: 360px"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-table
|
||||
:data="logs"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
class="log-table"
|
||||
:row-class-name="({ row }) => row.status === 'failure' ? 'failure-row' : ''"
|
||||
>
|
||||
<el-table-column prop="create_time" label="时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.create_time ? new Date(row.create_time).toLocaleString('zh-CN') : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" width="110" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.username || row.user_id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模块" width="110" align="center">
|
||||
<template #default="{ row }">{{ moduleLabel(row.module) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="动作" width="90" align="center">
|
||||
<template #default="{ row }">{{ actionLabel(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标" min-width="140" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.target_name || row.target_id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small" effect="dark">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="异常类型" width="160" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.error_type" type="danger" size="small" effect="plain">{{ row.error_type }}</el-tag>
|
||||
<span v-else style="color: #c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报错信息" min-width="250" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.error_message" class="error-text">{{ row.error_message }}</span>
|
||||
<span v-else style="color: #c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="耗时(ms)" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.duration_ms > 3000 ? '#e6a23c' : '' }">
|
||||
{{ row.duration_ms ? row.duration_ms : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="showDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="query.limit"
|
||||
:current-page="Math.floor((query.offset || 0) / (query.limit || 50)) + 1"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="操作日志详情" width="850px" top="5vh">
|
||||
<el-descriptions :column="2" border v-if="detailLog">
|
||||
<el-descriptions-item label="时间">
|
||||
{{ detailLog.create_time ? new Date(detailLog.create_time).toLocaleString('zh-CN') : '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTagType(detailLog.status)" size="small" effect="dark">
|
||||
{{ detailLog.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户">{{ detailLog.username || detailLog.user_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP">{{ detailLog.client_ip || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模块">{{ moduleLabel(detailLog.module) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="动作">{{ actionLabel(detailLog.action) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标类型">{{ detailLog.target_type || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标ID">{{ detailLog.target_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标名称">{{ detailLog.target_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时">{{ detailLog.duration_ms ? detailLog.duration_ms + ' ms' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="出错函数" :span="2">
|
||||
<span v-if="detailLog.func_name" class="func-name">{{ detailLog.func_name }}</span>
|
||||
<span v-else style="color: #c0c4cc">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方法">{{ detailLog.request_method || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="请求路径">{{ detailLog.request_path || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Trace ID" :span="2">{{ detailLog.trace_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="异常类型" :span="2">
|
||||
<el-tag v-if="detailLog.error_type" type="danger" size="small" effect="dark">{{ detailLog.error_type }}</el-tag>
|
||||
<span v-else style="color: #c0c4cc">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="报错信息" :span="2">
|
||||
<div v-if="detailLog.error_message" class="error-box">{{ detailLog.error_message }}</div>
|
||||
<span v-else style="color: #c0c4cc">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="异常堆栈 (Traceback)" :span="2">
|
||||
<pre v-if="detailLog.error_traceback" class="traceback-box">{{ detailLog.error_traceback }}</pre>
|
||||
<span v-else style="color: #c0c4cc">无堆栈信息</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="操作详情" :span="2">
|
||||
<pre v-if="detailLog.detail" class="detail-box">{{ detailLog.detail }}</pre>
|
||||
<span v-else style="color: #c0c4cc">-</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page { padding: 16px; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.page-title { margin: 0; font-size: 18px; }
|
||||
.stats-row { margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
.stat-value { font-size: 24px; font-weight: bold; }
|
||||
.stat-label { font-size: 12px; color: #909399; }
|
||||
.stat-modules { text-align: left; min-height: 40px; }
|
||||
}
|
||||
.filter-card { margin-bottom: 16px; }
|
||||
.log-table { margin-top: 8px; }
|
||||
.pager { margin-top: 12px; text-align: right; }
|
||||
.error-text { color: #f56c6c; font-weight: 500; }
|
||||
.func-name {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
background: #fdf6ec;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.error-box {
|
||||
color: #f56c6c;
|
||||
background: #fef0f0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.traceback-box {
|
||||
background: #2d2d2d;
|
||||
color: #f48771;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.detail-box {
|
||||
background: #f5f7fa;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
:deep(.failure-row) {
|
||||
background-color: #fef0f0 !important;
|
||||
}
|
||||
:deep(.failure-row:hover > td) {
|
||||
background-color: #fde2e2 !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user