feat(日志管理): 开发日志管理功能。

fix(项目任务): 1、任务完成后需要依然能够修改工作日志,但是只能修改工作内容和上传附件。2、任务完成后,协办人的工作日志不应该能删除、所有任务里的成员不能新增工作日志,前端不显示新增、删除按钮。3、团队成员的面板,在成员排序时,让有下属的成员提前。4、在任务弹出框有个快速用执行的信息填充的icon。
This commit is contained in:
dk
2026-06-25 21:34:23 +08:00
parent ea6a816d58
commit 570f284230
35 changed files with 2434 additions and 72 deletions

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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`;
}

View 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>