feat(日志管理): 开发日志管理功能。
fix(项目任务): 1、任务完成后需要依然能够修改工作日志,但是只能修改工作内容和上传附件。2、任务完成后,协办人的工作日志不应该能删除、所有任务里的成员不能新增工作日志,前端不显示新增、删除按钮。3、团队成员的面板,在成员排序时,让有下属的成员提前。4、在任务弹出框有个快速用执行的信息填充的icon。
This commit is contained in:
238
src/views/infra/log-management/api-access-log/index.vue
Normal file
238
src/views/infra/log-management/api-access-log/index.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { INFRA_OPERATE_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchExportApiAccessLog, fetchGetApiAccessLog, fetchGetApiAccessLogPage } from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import LogDetailDialog from '../shared/log-detail-dialog.vue';
|
||||
import {
|
||||
type LogDetailSection,
|
||||
LogPermission,
|
||||
downloadBlob,
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
getLogExportFileName
|
||||
} from '../shared';
|
||||
import ApiAccessLogSearch from './modules/search.vue';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
|
||||
defineOptions({ name: 'ApiAccessLogTab' });
|
||||
|
||||
type ApiAccessLogPageResponse = Awaited<ReturnType<typeof fetchGetApiAccessLogPage>>;
|
||||
|
||||
function createSearchParams(): Api.SystemLog.ApiAccess.SearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: undefined,
|
||||
userType: undefined,
|
||||
applicationName: undefined,
|
||||
requestUrl: undefined,
|
||||
beginTime: undefined,
|
||||
duration: undefined,
|
||||
resultCode: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: ApiAccessLogPageResponse, pageNo: number, pageSize: number) {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
const searchParams = reactive(createSearchParams());
|
||||
const detailVisible = ref(false);
|
||||
const currentRow = ref<Api.SystemLog.ApiAccess.Log | null>(null);
|
||||
const exporting = ref(false);
|
||||
const canExport = computed(() => hasAuth(LogPermission.ApiAccessExport));
|
||||
|
||||
const detailSections: LogDetailSection[] = [
|
||||
{
|
||||
title: '请求信息',
|
||||
fields: [
|
||||
{ label: '日志编号', key: 'id' },
|
||||
{ label: '链路追踪编号', key: 'traceId' },
|
||||
{ label: '应用名', key: 'applicationName' },
|
||||
{ label: '请求方式', key: 'requestMethod' },
|
||||
{ label: '请求地址', key: 'requestUrl', span: 2 },
|
||||
{ label: '开始时间', key: 'beginTime', type: 'datetime' },
|
||||
{ label: '结束时间', key: 'endTime', type: 'datetime' },
|
||||
{ label: '执行时长', formatter: detail => formatDuration(detail.duration) },
|
||||
{ label: '结果码', key: 'resultCode' },
|
||||
{ label: '结果提示', key: 'resultMsg', span: 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '业务上下文',
|
||||
fields: [
|
||||
{ label: '用户编号', key: 'userId' },
|
||||
{ label: '操作模块', key: 'operateModule' },
|
||||
{ label: '操作名', key: 'operateName' },
|
||||
{ label: '操作分类', key: 'operateType', type: 'dict', dictCode: INFRA_OPERATE_TYPE_DICT_CODE },
|
||||
{ label: '用户IP', key: 'userIp' },
|
||||
{ label: '浏览器UA', key: 'userAgent', type: 'multiline' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '报文内容',
|
||||
fields: [
|
||||
{ label: '请求参数', key: 'requestParams', type: 'multiline' },
|
||||
{ label: '响应结果', key: 'responseBody', type: 'multiline' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
ApiAccessLogPageResponse,
|
||||
Api.SystemLog.ApiAccess.Log
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetApiAccessLogPage(searchParams),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{ prop: 'applicationName', label: '应用名', minWidth: 140, showOverflowTooltip: true },
|
||||
{ prop: 'requestMethod', label: '请求方式', width: 100, align: 'center' },
|
||||
{ prop: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true },
|
||||
{ prop: 'operateModule', label: '操作模块', minWidth: 140, showOverflowTooltip: true },
|
||||
{ prop: 'operateName', label: '操作名', minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'operateType',
|
||||
label: '操作分类',
|
||||
minWidth: 120,
|
||||
formatter: row => <DictText dictCode={INFRA_OPERATE_TYPE_DICT_CODE} value={row.operateType} />
|
||||
},
|
||||
{ prop: 'resultCode', label: '结果码', width: 100, align: 'center' },
|
||||
{ prop: 'duration', label: '执行时长', width: 120, formatter: row => formatDuration(row.duration) },
|
||||
{ prop: 'beginTime', label: '请求时间', minWidth: 180, formatter: row => formatDateTime(row.beginTime) },
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
function getRowActions(row: Api.SystemLog.ApiAccess.Log): BusinessTableAction[] {
|
||||
return [
|
||||
{
|
||||
key: 'view',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: IconMdiEyeOutline,
|
||||
onClick: () => openDetail(row)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function openDetail(row: Api.SystemLog.ApiAccess.Log) {
|
||||
currentRow.value = row;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
async function reloadTable(page = searchParams.pageNo) {
|
||||
await getDataByPage(page);
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
Object.assign(searchParams, createSearchParams());
|
||||
reloadTable(1);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
reloadTable(1);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await fetchExportApiAccessLog(searchParams);
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadBlob(blob, getLogExportFileName('API访问日志'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||
<ApiAccessLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>API访问日志列表</p>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton v-if="canExport" plain type="primary" :loading="exporting" @click="handleExport">
|
||||
<template #icon>
|
||||
<icon-mdi-download class="text-icon" />
|
||||
</template>
|
||||
导出
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<LogDetailDialog
|
||||
v-model:visible="detailVisible"
|
||||
title="API访问日志详情"
|
||||
:row-data="currentRow"
|
||||
:sections="detailSections"
|
||||
:fetch-detail="fetchGetApiAccessLog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { SYSTEM_USER_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'ApiAccessLogSearch' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const model = defineModel<Api.SystemLog.ApiAccess.SearchParams>('model', { required: true });
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'applicationName',
|
||||
label: '应用名',
|
||||
type: 'input',
|
||||
placeholder: '请输入应用名'
|
||||
},
|
||||
{
|
||||
key: 'requestUrl',
|
||||
label: '请求地址',
|
||||
type: 'input',
|
||||
placeholder: '请输入请求地址'
|
||||
},
|
||||
{
|
||||
key: 'resultCode',
|
||||
label: '结果码',
|
||||
type: 'input',
|
||||
placeholder: '请输入结果码',
|
||||
transformValue: value => {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return undefined;
|
||||
const resultCode = Number(text);
|
||||
return Number.isFinite(resultCode) ? resultCode : undefined;
|
||||
},
|
||||
resolveValue: value => (value === null || value === undefined ? '' : String(value))
|
||||
},
|
||||
{
|
||||
key: 'duration',
|
||||
label: '最小时长(ms)',
|
||||
type: 'input',
|
||||
placeholder: '请输入执行时长下限',
|
||||
transformValue: value => {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!text) return undefined;
|
||||
const duration = Number(text);
|
||||
return Number.isFinite(duration) ? duration : undefined;
|
||||
},
|
||||
resolveValue: value => (value === null || value === undefined ? '' : String(value))
|
||||
},
|
||||
{
|
||||
key: 'userId',
|
||||
label: '用户编号',
|
||||
type: 'input',
|
||||
placeholder: '请输入用户编号'
|
||||
},
|
||||
{
|
||||
key: 'userType',
|
||||
label: '用户类型',
|
||||
type: 'dict',
|
||||
placeholder: '请选择用户类型',
|
||||
dictCode: SYSTEM_USER_TYPE_DICT_CODE,
|
||||
filterable: true
|
||||
},
|
||||
{
|
||||
key: 'beginTime',
|
||||
label: '请求时间',
|
||||
type: 'dateRange',
|
||||
placeholder: '请选择请求时间',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
|
||||
</template>
|
||||
230
src/views/infra/log-management/api-error-log/index.vue
Normal file
230
src/views/infra/log-management/api-error-log/index.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchExportApiErrorLog, fetchGetApiErrorLog, fetchGetApiErrorLogPage } from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import LogDetailDialog from '../shared/log-detail-dialog.vue';
|
||||
import { type LogDetailSection, LogPermission, downloadBlob, formatDateTime, getLogExportFileName } from '../shared';
|
||||
import ApiErrorLogSearch from './modules/search.vue';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
|
||||
defineOptions({ name: 'ApiErrorLogTab' });
|
||||
|
||||
type ApiErrorLogPageResponse = Awaited<ReturnType<typeof fetchGetApiErrorLogPage>>;
|
||||
|
||||
function createSearchParams(): Api.SystemLog.ApiError.SearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: undefined,
|
||||
userType: undefined,
|
||||
applicationName: undefined,
|
||||
requestUrl: undefined,
|
||||
exceptionTime: undefined,
|
||||
processStatus: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: ApiErrorLogPageResponse, pageNo: number, pageSize: number) {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
const searchParams = reactive(createSearchParams());
|
||||
const detailVisible = ref(false);
|
||||
const currentRow = ref<Api.SystemLog.ApiError.Log | null>(null);
|
||||
const exporting = ref(false);
|
||||
const canExport = computed(() => hasAuth(LogPermission.ApiErrorExport));
|
||||
|
||||
const detailSections: LogDetailSection[] = [
|
||||
{
|
||||
title: '异常信息',
|
||||
fields: [
|
||||
{ label: '编号', key: 'id' },
|
||||
{ label: '链路追踪编号', key: 'traceId' },
|
||||
{ label: '应用名', key: 'applicationName' },
|
||||
{ label: '请求方式', key: 'requestMethod' },
|
||||
{ label: '请求地址', key: 'requestUrl', span: 2 },
|
||||
{ label: '异常时间', key: 'exceptionTime', type: 'datetime' },
|
||||
{ label: '异常名', key: 'exceptionName' },
|
||||
{ label: '异常消息', key: 'exceptionMessage', type: 'multiline' },
|
||||
{ label: '根因消息', key: 'exceptionRootCauseMessage', type: 'multiline' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '上下文',
|
||||
fields: [
|
||||
{ label: '用户编号', key: 'userId' },
|
||||
{ label: '用户IP', key: 'userIp' },
|
||||
{ label: '浏览器UA', key: 'userAgent', type: 'multiline' },
|
||||
{ label: '请求参数', key: 'requestParams', type: 'multiline' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '堆栈与处理',
|
||||
fields: [
|
||||
{ label: '异常类名', key: 'exceptionClassName' },
|
||||
{ label: '异常文件', key: 'exceptionFileName' },
|
||||
{ label: '异常方法', key: 'exceptionMethodName' },
|
||||
{ label: '异常行号', key: 'exceptionLineNumber' },
|
||||
{ label: '处理状态', key: 'processStatus', type: 'dict', dictCode: INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE },
|
||||
{ label: '处理时间', key: 'processTime', type: 'datetime' },
|
||||
{ label: '处理用户编号', key: 'processUserId', span: 2 },
|
||||
{ label: '异常栈轨迹', key: 'exceptionStackTrace', type: 'multiline' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
ApiErrorLogPageResponse,
|
||||
Api.SystemLog.ApiError.Log
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetApiErrorLogPage(searchParams),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{ prop: 'applicationName', label: '应用名', minWidth: 140, showOverflowTooltip: true },
|
||||
{ prop: 'requestMethod', label: '请求方式', width: 100, align: 'center' },
|
||||
{ prop: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true },
|
||||
{ prop: 'exceptionName', label: '异常名', minWidth: 180, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'processStatus',
|
||||
label: '处理状态',
|
||||
minWidth: 120,
|
||||
formatter: row => <DictText dictCode={INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE} value={row.processStatus} />
|
||||
},
|
||||
{ prop: 'exceptionTime', label: '异常时间', minWidth: 180, formatter: row => formatDateTime(row.exceptionTime) },
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
function getRowActions(row: Api.SystemLog.ApiError.Log): BusinessTableAction[] {
|
||||
return [
|
||||
{
|
||||
key: 'view',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: IconMdiEyeOutline,
|
||||
onClick: () => openDetail(row)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function openDetail(row: Api.SystemLog.ApiError.Log) {
|
||||
currentRow.value = row;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
async function reloadTable(page = searchParams.pageNo) {
|
||||
await getDataByPage(page);
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
Object.assign(searchParams, createSearchParams());
|
||||
reloadTable(1);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
reloadTable(1);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await fetchExportApiErrorLog(searchParams);
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadBlob(blob, getLogExportFileName('API错误日志'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||
<ApiErrorLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>API错误日志列表</p>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton v-if="canExport" plain type="primary" :loading="exporting" @click="handleExport">
|
||||
<template #icon>
|
||||
<icon-mdi-download class="text-icon" />
|
||||
</template>
|
||||
导出
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<LogDetailDialog
|
||||
v-model:visible="detailVisible"
|
||||
title="API错误日志详情"
|
||||
:row-data="currentRow"
|
||||
:sections="detailSections"
|
||||
:fetch-detail="fetchGetApiErrorLog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE, SYSTEM_USER_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'ApiErrorLogSearch' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const model = defineModel<Api.SystemLog.ApiError.SearchParams>('model', { required: true });
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'applicationName',
|
||||
label: '应用名',
|
||||
type: 'input',
|
||||
placeholder: '请输入应用名'
|
||||
},
|
||||
{
|
||||
key: 'requestUrl',
|
||||
label: '请求地址',
|
||||
type: 'input',
|
||||
placeholder: '请输入请求地址'
|
||||
},
|
||||
{
|
||||
key: 'processStatus',
|
||||
label: '处理状态',
|
||||
type: 'dict',
|
||||
placeholder: '请选择处理状态',
|
||||
dictCode: INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE,
|
||||
filterable: true
|
||||
},
|
||||
{
|
||||
key: 'userId',
|
||||
label: '用户编号',
|
||||
type: 'input',
|
||||
placeholder: '请输入用户编号'
|
||||
},
|
||||
{
|
||||
key: 'userType',
|
||||
label: '用户类型',
|
||||
type: 'dict',
|
||||
placeholder: '请选择用户类型',
|
||||
dictCode: SYSTEM_USER_TYPE_DICT_CODE,
|
||||
filterable: true
|
||||
},
|
||||
{
|
||||
key: 'exceptionTime',
|
||||
label: '异常时间',
|
||||
type: 'dateRange',
|
||||
placeholder: '请选择异常时间',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
|
||||
</template>
|
||||
177
src/views/infra/log-management/index.vue
Normal file
177
src/views/infra/log-management/index.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, ref, watch } from 'vue';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import LoginLogTab from './login-log/index.vue';
|
||||
import OperateLogTab from './operate-log/index.vue';
|
||||
import ApiAccessLogTab from './api-access-log/index.vue';
|
||||
import ApiErrorLogTab from './api-error-log/index.vue';
|
||||
import { LOG_TABS, type LogTabKey } from './shared';
|
||||
|
||||
defineOptions({ name: 'LogManagement' });
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
const activeTab = ref<LogTabKey>('login-log');
|
||||
|
||||
const visibleTabs = computed(() => LOG_TABS.filter(tab => hasAuth(tab.queryPermission)));
|
||||
const activeTabMeta = computed(() => visibleTabs.value.find(tab => tab.name === activeTab.value) || null);
|
||||
|
||||
const scopeOptions = computed(() =>
|
||||
visibleTabs.value.map(tab => ({
|
||||
label: tab.label,
|
||||
value: tab.name
|
||||
}))
|
||||
);
|
||||
|
||||
const componentMap: Record<LogTabKey, object> = {
|
||||
'login-log': markRaw(LoginLogTab),
|
||||
'operate-log': markRaw(OperateLogTab),
|
||||
'api-access-log': markRaw(ApiAccessLogTab),
|
||||
'api-error-log': markRaw(ApiErrorLogTab)
|
||||
};
|
||||
|
||||
watch(
|
||||
visibleTabs,
|
||||
tabs => {
|
||||
if (!tabs.length) return;
|
||||
if (!tabs.some(tab => tab.name === activeTab.value)) {
|
||||
activeTab.value = tabs[0].name;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="log-management-page">
|
||||
<ElCard class="log-management-page__context" body-class="log-management-page__context-body">
|
||||
<div v-if="visibleTabs.length" class="log-management-page__context-layout">
|
||||
<div class="log-management-page__context-controls">
|
||||
<ElSegmented v-model="activeTab" :options="scopeOptions" class="log-management-page__segmented" />
|
||||
</div>
|
||||
|
||||
<div class="log-management-page__context-info">
|
||||
<div class="log-management-page__context-main">
|
||||
<div class="log-management-page__context-item">
|
||||
<span class="log-management-page__context-label">当前日志</span>
|
||||
<strong class="log-management-page__context-value">{{ activeTabMeta?.label || '--' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="log-management-page__context-desc">
|
||||
{{ activeTabMeta?.description || '当前查看系统日志数据。' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-else description="暂无可查看的日志权限" />
|
||||
</ElCard>
|
||||
|
||||
<div v-if="activeTabMeta" class="log-management-page__content">
|
||||
<KeepAlive>
|
||||
<component :is="componentMap[activeTab]" />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.log-management-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-management-page__context {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-fill-color-blank);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.log-management-page__context-body) {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.log-management-page__context-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.log-management-page__context-controls {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.log-management-page__segmented) {
|
||||
padding: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.log-management-page__segmented .el-segmented__item) {
|
||||
min-width: 128px;
|
||||
min-height: 40px;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-management-page__context-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.log-management-page__context-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.log-management-page__context-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.log-management-page__context-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-management-page__context-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-management-page__context-desc {
|
||||
margin: 10px 0 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.log-management-page__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.log-management-page__context-layout {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.log-management-page__context-info {
|
||||
padding-left: 0;
|
||||
padding-top: 14px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
235
src/views/infra/log-management/login-log/index.vue
Normal file
235
src/views/infra/log-management/login-log/index.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
import { SYSTEM_LOGIN_RESULT_DICT_CODE, SYSTEM_LOGIN_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchExportLoginLog, fetchGetLoginLog, fetchGetLoginLogPage } from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import LogDetailDialog from '../shared/log-detail-dialog.vue';
|
||||
import { type LogDetailSection, LogPermission, downloadBlob, formatDateTime, getLogExportFileName } from '../shared';
|
||||
import LoginLogSearch from './modules/search.vue';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
|
||||
defineOptions({ name: 'LoginLogTab' });
|
||||
|
||||
type LoginLogPageResponse = Awaited<ReturnType<typeof fetchGetLoginLogPage>>;
|
||||
|
||||
function createSearchParams(): Api.SystemLog.Login.SearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userIp: undefined,
|
||||
username: undefined,
|
||||
status: undefined,
|
||||
createTime: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: LoginLogPageResponse, pageNo: number, pageSize: number) {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
const searchParams = reactive(createSearchParams());
|
||||
const detailVisible = ref(false);
|
||||
const currentRow = ref<Api.SystemLog.Login.Log | null>(null);
|
||||
const exporting = ref(false);
|
||||
const canExport = computed(() => hasAuth(LogPermission.LoginExport));
|
||||
|
||||
const detailSections: LogDetailSection[] = [
|
||||
{
|
||||
title: '基础信息',
|
||||
fields: [
|
||||
{ label: '日志编号', key: 'id' },
|
||||
{ label: '日志类型', key: 'logType', type: 'dict', dictCode: SYSTEM_LOGIN_TYPE_DICT_CODE },
|
||||
{ label: '用户编号', key: 'userId' },
|
||||
{ label: '账号', key: 'username' },
|
||||
{ label: '登录结果', key: 'result', type: 'dict', dictCode: SYSTEM_LOGIN_RESULT_DICT_CODE },
|
||||
{ label: '登录时间', key: 'createTime', type: 'datetime' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '访问上下文',
|
||||
fields: [
|
||||
{ label: '链路追踪编号', key: 'traceId' },
|
||||
{ label: '登录IP', key: 'userIp' },
|
||||
{ label: '浏览器UA', key: 'userAgent', type: 'multiline' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
LoginLogPageResponse,
|
||||
Api.SystemLog.Login.Log
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetLoginLogPage(searchParams),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{
|
||||
prop: 'username',
|
||||
label: '账号',
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'logType',
|
||||
label: '日志类型',
|
||||
minWidth: 120,
|
||||
formatter: row => <DictText dictCode={SYSTEM_LOGIN_TYPE_DICT_CODE} value={row.logType} />
|
||||
},
|
||||
{
|
||||
prop: 'result',
|
||||
label: '登录结果',
|
||||
minWidth: 80,
|
||||
formatter: row => (
|
||||
<ElTag type={row.result === 1 ? 'success' : 'danger'}>
|
||||
<DictText dictCode={SYSTEM_LOGIN_RESULT_DICT_CODE} value={row.result} />
|
||||
</ElTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'userIp',
|
||||
label: '登录IP',
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: '登录时间',
|
||||
minWidth: 180,
|
||||
formatter: row => formatDateTime(row.createTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
function getRowActions(row: Api.SystemLog.Login.Log): BusinessTableAction[] {
|
||||
return [
|
||||
{
|
||||
key: 'view',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: IconMdiEyeOutline,
|
||||
onClick: () => openDetail(row)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function openDetail(row: Api.SystemLog.Login.Log) {
|
||||
currentRow.value = row;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
async function reloadTable(page = searchParams.pageNo) {
|
||||
await getDataByPage(page);
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
Object.assign(searchParams, createSearchParams());
|
||||
reloadTable(1);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
reloadTable(1);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await fetchExportLoginLog(searchParams);
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadBlob(blob, getLogExportFileName('登录日志'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||
<LoginLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>登录日志列表</p>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton v-if="canExport" plain type="primary" :loading="exporting" @click="handleExport">
|
||||
<template #icon>
|
||||
<icon-mdi-download class="text-icon" />
|
||||
</template>
|
||||
导出
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<LogDetailDialog
|
||||
v-model:visible="detailVisible"
|
||||
title="登录日志详情"
|
||||
:row-data="currentRow"
|
||||
:sections="detailSections"
|
||||
:fetch-detail="fetchGetLoginLog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
60
src/views/infra/log-management/login-log/modules/search.vue
Normal file
60
src/views/infra/log-management/login-log/modules/search.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'LoginLogSearch' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const model = defineModel<Api.SystemLog.Login.SearchParams>('model', { required: true });
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '成功', value: 1 },
|
||||
{ label: '失败', value: 0 }
|
||||
];
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'username',
|
||||
label: '账号',
|
||||
type: 'input',
|
||||
placeholder: '请输入用户账号'
|
||||
},
|
||||
{
|
||||
key: 'userIp',
|
||||
label: '登录IP',
|
||||
type: 'input',
|
||||
placeholder: '请输入登录 IP'
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '登录结果',
|
||||
type: 'select',
|
||||
placeholder: '请选择登录结果',
|
||||
options: statusOptions,
|
||||
transformValue: value => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
return Number(value) === 1;
|
||||
},
|
||||
resolveValue: value => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'createTime',
|
||||
label: '登录时间',
|
||||
type: 'dateRange',
|
||||
placeholder: '请选择登录时间',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
|
||||
</template>
|
||||
244
src/views/infra/log-management/operate-log/index.vue
Normal file
244
src/views/infra/log-management/operate-log/index.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import {
|
||||
fetchExportOperateLog,
|
||||
fetchGetOperateLog,
|
||||
fetchGetOperateLogPage,
|
||||
fetchGetUserSimpleList
|
||||
} from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import LogDetailDialog from '../shared/log-detail-dialog.vue';
|
||||
import { type LogDetailSection, LogPermission, downloadBlob, formatDateTime, getLogExportFileName } from '../shared';
|
||||
import OperateLogSearch from './modules/search.vue';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
|
||||
defineOptions({ name: 'OperateLogTab' });
|
||||
|
||||
type OperateLogPageResponse = Awaited<ReturnType<typeof fetchGetOperateLogPage>>;
|
||||
|
||||
function createSearchParams(): Api.SystemLog.Operate.SearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: undefined,
|
||||
type: undefined,
|
||||
requestMethod: undefined,
|
||||
subType: undefined,
|
||||
action: undefined,
|
||||
createTime: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: OperateLogPageResponse, pageNo: number, pageSize: number) {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
const searchParams = reactive(createSearchParams());
|
||||
const detailVisible = ref(false);
|
||||
const currentRow = ref<Api.SystemLog.Operate.Log | null>(null);
|
||||
const exporting = ref(false);
|
||||
const userOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const canExport = computed(() => hasAuth(LogPermission.OperateExport));
|
||||
|
||||
const detailSections: LogDetailSection[] = [
|
||||
{
|
||||
title: '操作信息',
|
||||
fields: [
|
||||
{ label: '日志编号', key: 'id' },
|
||||
{ label: '操作人', key: 'userName' },
|
||||
{ label: '用户编号', key: 'userId' },
|
||||
{ label: '模块类型', key: 'type' },
|
||||
{ label: '操作名', key: 'subType' },
|
||||
{ label: '业务编号', key: 'bizId' },
|
||||
{ label: '请求方式', key: 'requestMethod' },
|
||||
{ label: '请求地址', key: 'requestUrl', span: 2 },
|
||||
{ label: '操作时间', key: 'createTime', type: 'datetime' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '上下文',
|
||||
fields: [
|
||||
{ label: '链路追踪编号', key: 'traceId' },
|
||||
{ label: '用户IP', key: 'userIp' },
|
||||
{ label: '浏览器UA', key: 'userAgent', type: 'multiline' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '详细内容',
|
||||
fields: [
|
||||
{ label: '操作明细', key: 'action', type: 'multiline' },
|
||||
{ label: '扩展字段', key: 'extra', type: 'multiline' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
OperateLogPageResponse,
|
||||
Api.SystemLog.Operate.Log
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetOperateLogPage(searchParams),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{ prop: 'userName', label: '操作人', minWidth: 120, showOverflowTooltip: true },
|
||||
{ prop: 'type', label: '模块类型', minWidth: 140, showOverflowTooltip: true },
|
||||
{ prop: 'subType', label: '操作名', minWidth: 140, showOverflowTooltip: true },
|
||||
// { prop: 'bizId', label: '业务编号', minWidth: 120, showOverflowTooltip: true },
|
||||
{ prop: 'requestMethod', label: '请求方式', width: 150, align: 'center' },
|
||||
{ prop: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true },
|
||||
{ prop: 'createTime', label: '操作时间', minWidth: 180, formatter: row => formatDateTime(row.createTime) },
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
function getRowActions(row: Api.SystemLog.Operate.Log): BusinessTableAction[] {
|
||||
return [
|
||||
{
|
||||
key: 'view',
|
||||
label: '查看',
|
||||
buttonType: 'primary',
|
||||
icon: IconMdiEyeOutline,
|
||||
onClick: () => openDetail(row)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function openDetail(row: Api.SystemLog.Operate.Log) {
|
||||
currentRow.value = row;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
async function reloadTable(page = searchParams.pageNo) {
|
||||
await getDataByPage(page);
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
Object.assign(searchParams, createSearchParams());
|
||||
reloadTable(1);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
reloadTable(1);
|
||||
}
|
||||
|
||||
async function loadUserOptions() {
|
||||
const { error, data: userList } = await fetchGetUserSimpleList();
|
||||
|
||||
if (error || !userList) {
|
||||
userOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
userOptions.value = userList.map((item: Api.SystemManage.UserSimple) => ({
|
||||
label: item.username ? `${item.nickname}(${item.username})` : item.nickname,
|
||||
value: item.id
|
||||
}));
|
||||
}
|
||||
|
||||
loadUserOptions();
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await fetchExportOperateLog(searchParams);
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadBlob(blob, getLogExportFileName('操作日志'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||
<OperateLogSearch
|
||||
v-model:model="searchParams"
|
||||
:user-options="userOptions"
|
||||
@reset="resetSearchParams"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>操作日志列表</p>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton v-if="canExport" plain type="primary" :loading="exporting" @click="handleExport">
|
||||
<template #icon>
|
||||
<icon-mdi-download class="text-icon" />
|
||||
</template>
|
||||
导出
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<LogDetailDialog
|
||||
v-model:visible="detailVisible"
|
||||
title="操作日志详情"
|
||||
:row-data="currentRow"
|
||||
:sections="detailSections"
|
||||
:fetch-detail="fetchGetOperateLog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { SYSTEM_REQUEST_METHOD_DICT_CODE } from '@/constants/dict';
|
||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'OperateLogSearch' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const model = defineModel<Api.SystemLog.Operate.SearchParams>('model', { required: true });
|
||||
|
||||
const props = defineProps<{
|
||||
userOptions: Array<{ label: string; value: string }>;
|
||||
}>();
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'userId',
|
||||
label: '操作人',
|
||||
type: 'select',
|
||||
placeholder: '请选择操作人',
|
||||
options: props.userOptions,
|
||||
filterable: true
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: '模块类型',
|
||||
type: 'input',
|
||||
placeholder: '请输入操作模块类型'
|
||||
},
|
||||
{
|
||||
key: 'requestMethod',
|
||||
label: '请求方式',
|
||||
type: 'dict',
|
||||
placeholder: '请选择请求方式',
|
||||
dictCode: SYSTEM_REQUEST_METHOD_DICT_CODE,
|
||||
filterable: true
|
||||
},
|
||||
{
|
||||
key: 'subType',
|
||||
label: '操作名',
|
||||
type: 'input',
|
||||
placeholder: '请输入操作名'
|
||||
},
|
||||
{
|
||||
key: 'createTime',
|
||||
label: '操作时间',
|
||||
type: 'dateRange',
|
||||
placeholder: '请选择操作时间',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="emit('reset')" @search="emit('search')" />
|
||||
</template>
|
||||
123
src/views/infra/log-management/shared.ts
Normal file
123
src/views/infra/log-management/shared.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export type LogTabKey = 'login-log' | 'operate-log' | 'api-access-log' | 'api-error-log';
|
||||
|
||||
export interface LogTabOption {
|
||||
name: LogTabKey;
|
||||
label: string;
|
||||
description: string;
|
||||
queryPermission: string;
|
||||
exportPermission: string;
|
||||
}
|
||||
|
||||
export interface LogDetailField {
|
||||
label: string;
|
||||
key?: string;
|
||||
span?: number;
|
||||
type?: 'text' | 'datetime' | 'dict' | 'multiline';
|
||||
dictCode?: string;
|
||||
formatter?: (detail: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
export interface LogDetailSection {
|
||||
title: string;
|
||||
fields: LogDetailField[];
|
||||
}
|
||||
|
||||
export const LogPermission = {
|
||||
LoginQuery: 'system:login-log:query',
|
||||
LoginExport: 'system:login-log:export',
|
||||
OperateQuery: 'system:operate-log:query',
|
||||
OperateExport: 'system:operate-log:export',
|
||||
ApiAccessQuery: 'system:api-access-log:query',
|
||||
ApiAccessExport: 'system:api-access-log:export',
|
||||
ApiErrorQuery: 'system:api-error-log:query',
|
||||
ApiErrorExport: 'system:api-error-log:export'
|
||||
} as const;
|
||||
|
||||
export const LOG_TABS: LogTabOption[] = [
|
||||
{
|
||||
name: 'login-log',
|
||||
label: '登录日志',
|
||||
description: '查看系统登录行为、登录结果与登录时间。',
|
||||
queryPermission: LogPermission.LoginQuery,
|
||||
exportPermission: LogPermission.LoginExport
|
||||
},
|
||||
{
|
||||
name: 'operate-log',
|
||||
label: '操作日志',
|
||||
description: '查看系统操作轨迹、请求地址与业务编号。',
|
||||
queryPermission: LogPermission.OperateQuery,
|
||||
exportPermission: LogPermission.OperateExport
|
||||
},
|
||||
{
|
||||
name: 'api-access-log',
|
||||
label: 'API访问日志',
|
||||
description: '查看接口访问结果、执行时长与请求链路。',
|
||||
queryPermission: LogPermission.ApiAccessQuery,
|
||||
exportPermission: LogPermission.ApiAccessExport
|
||||
},
|
||||
{
|
||||
name: 'api-error-log',
|
||||
label: 'API错误日志',
|
||||
description: '查看接口异常、处理状态与错误上下文。',
|
||||
queryPermission: LogPermission.ApiErrorQuery,
|
||||
exportPermission: LogPermission.ApiErrorExport
|
||||
}
|
||||
];
|
||||
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) return '--';
|
||||
|
||||
const target = dayjs(value);
|
||||
return target.isValid() ? target.format('YYYY-MM-DD HH:mm:ss') : value;
|
||||
}
|
||||
|
||||
export function formatText(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatDuration(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
|
||||
const duration = Number(value);
|
||||
return Number.isFinite(duration) ? `${duration} ms` : String(value);
|
||||
}
|
||||
|
||||
export function formatMultilineText(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return '--';
|
||||
|
||||
const text = String(value);
|
||||
const normalized = text.trim();
|
||||
|
||||
if (!normalized) return '--';
|
||||
|
||||
if (
|
||||
(normalized.startsWith('{') && normalized.endsWith('}')) ||
|
||||
(normalized.startsWith('[') && normalized.endsWith(']'))
|
||||
) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(normalized), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function getLogExportFileName(label: string) {
|
||||
return `${label}_${dayjs().format('YYYY-MM-DD')}.xls`;
|
||||
}
|
||||
137
src/views/infra/log-management/shared/log-detail-dialog.vue
Normal file
137
src/views/infra/log-management/shared/log-detail-dialog.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import { type LogDetailField, type LogDetailSection, formatDateTime, formatMultilineText, formatText } from '../shared';
|
||||
|
||||
defineOptions({ name: 'LogDetailDialog' });
|
||||
|
||||
type DetailRecord = { id: string };
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
rowData?: { id: string } | null;
|
||||
sections: LogDetailSection[];
|
||||
fetchDetail: (id: string) => Promise<{
|
||||
error: unknown;
|
||||
data: DetailRecord | null;
|
||||
}>;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref<DetailRecord | null>(null);
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.rowData?.id] as const,
|
||||
([isVisible]) => {
|
||||
if (!isVisible) return;
|
||||
loadDetail();
|
||||
}
|
||||
);
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.rowData?.id) {
|
||||
detail.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const result = await props.fetchDetail(props.rowData.id);
|
||||
loading.value = false;
|
||||
|
||||
detail.value = !result.error && result.data ? result.data : null;
|
||||
}
|
||||
|
||||
function getFieldValue(field: LogDetailField) {
|
||||
if (!detail.value || !field.key) return undefined;
|
||||
return (detail.value as Record<string, unknown>)[field.key];
|
||||
}
|
||||
|
||||
function getDictFieldValue(field: LogDetailField): string | number | null | undefined {
|
||||
const value = getFieldValue(field);
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return typeof value === 'string' || typeof value === 'number' ? value : String(value);
|
||||
}
|
||||
|
||||
function getFieldText(field: LogDetailField) {
|
||||
if (field.formatter && detail.value) {
|
||||
return field.formatter(detail.value);
|
||||
}
|
||||
|
||||
const value = getFieldValue(field);
|
||||
|
||||
if (field.type === 'datetime') {
|
||||
return formatDateTime(value as string | null | undefined);
|
||||
}
|
||||
|
||||
if (field.type === 'multiline') {
|
||||
return formatMultilineText(value);
|
||||
}
|
||||
|
||||
return formatText(value);
|
||||
}
|
||||
|
||||
function getFieldSpan(field: LogDetailField) {
|
||||
return field.span || (field.type === 'multiline' ? 2 : 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" :title="title" preset="lg" :loading="loading" :show-footer="false">
|
||||
<div v-if="detail" class="log-detail-dialog">
|
||||
<BusinessFormSection v-for="section in sections" :key="section.title" :title="section.title">
|
||||
<ElDescriptions class="log-detail-dialog__descriptions" :column="2" border size="small">
|
||||
<ElDescriptionsItem
|
||||
v-for="field in section.fields"
|
||||
:key="`${section.title}-${field.label}`"
|
||||
:label="field.label"
|
||||
label-class-name="log-detail-dialog__label"
|
||||
:span="getFieldSpan(field)"
|
||||
>
|
||||
<DictText v-if="field.type === 'dict'" :dict-code="field.dictCode!" :value="getDictFieldValue(field)" />
|
||||
<div v-else-if="field.type === 'multiline'" class="log-detail-dialog__multiline">
|
||||
{{ getFieldText(field) }}
|
||||
</div>
|
||||
<span v-else>{{ getFieldText(field) }}</span>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂无日志详情" />
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-detail-dialog {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.log-detail-dialog__descriptions .el-descriptions__cell) {
|
||||
line-height: 1.7;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:deep(.log-detail-dialog__label) {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.log-detail-dialog__multiline {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user