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

@@ -209,9 +209,30 @@ export function setupElegantRouter() {
order: 1,
keepAlive: true
},
'infra_log-management': {
icon: 'mdi:text-box-search-outline',
order: 2,
keepAlive: true
},
'infra_log-management_login-log': {
hideInMenu: true,
activeMenu: 'infra_log-management'
},
'infra_log-management_operate-log': {
hideInMenu: true,
activeMenu: 'infra_log-management'
},
'infra_log-management_api-access-log': {
hideInMenu: true,
activeMenu: 'infra_log-management'
},
'infra_log-management_api-error-log': {
hideInMenu: true,
activeMenu: 'infra_log-management'
},
'infra_rd-code': {
icon: 'mdi:identifier',
order: 2,
order: 3,
keepAlive: true
}
};

View File

@@ -1,12 +1,12 @@
{
"generatedAt": "2026-06-05T03:08:01.803Z",
"generatedAt": "2026-06-25T05:22:43.905Z",
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
"rules": {
"directoryComponent": "layout.base",
"pageComponentPattern": "view.<routeName>",
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
},
"total": 22,
"total": 23,
"items": [
{
"name": "product_list",
@@ -338,6 +338,39 @@
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-application",
"path": "/personal-center/my-application",
"component": "view.personal-center_my-application",
"title": "我的申请",
"routeTitle": "personal-center_my-application",
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"localIcon": null,
"order": 4,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "我的申请",
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"localIcon": null,
"order": 4,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_my-performance",
"path": "/personal-center/my-performance",
@@ -372,15 +405,15 @@
"source": "generated"
},
{
"name": "personal-center_my-application",
"path": "/personal-center/my-application",
"component": "view.personal-center_my-application",
"title": "我的申请",
"routeTitle": "personal-center_my-application",
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"name": "personal-center_overtime-application",
"path": "/personal-center/overtime-application",
"component": "view.personal-center_overtime-application",
"title": "加班申请",
"routeTitle": "personal-center_overtime-application",
"i18nKey": "route.personal-center_overtime-application",
"icon": "mdi:clock-plus-outline",
"localIcon": null,
"order": 5,
"order": 6,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
@@ -389,11 +422,11 @@
"redirect": null,
"props": null,
"meta": {
"title": "我的申请",
"i18nKey": "route.personal-center_my-application",
"icon": "mdi:file-document-outline",
"title": "加班申请",
"i18nKey": "route.personal-center_overtime-application",
"icon": "mdi:clock-plus-outline",
"localIcon": null,
"order": 5,
"order": 6,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
@@ -437,39 +470,6 @@
"pageType": "leaf",
"source": "generated"
},
{
"name": "personal-center_overtime-application",
"path": "/personal-center/overtime-application",
"component": "view.personal-center_overtime-application",
"title": "加班申请",
"routeTitle": "personal-center_overtime-application",
"i18nKey": "route.personal-center_overtime-application",
"icon": "mdi:clock-plus-outline",
"localIcon": null,
"order": 6,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "加班申请",
"i18nKey": "route.personal-center_overtime-application",
"icon": "mdi:clock-plus-outline",
"localIcon": null,
"order": 6,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "personal-center",
"pageType": "leaf",
"source": "generated"
},
{
"name": "system_user",
"path": "/system/user",
@@ -701,6 +701,39 @@
"pageType": "leaf",
"source": "generated"
},
{
"name": "infra_log-management",
"path": "/infra/log-management",
"component": "view.infra_log-management",
"title": "日志管理",
"routeTitle": "infra_log-management",
"i18nKey": "route.infra_log-management",
"icon": "mdi:text-box-search-outline",
"localIcon": null,
"order": 2,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "日志管理",
"i18nKey": "route.infra_log-management",
"icon": "mdi:text-box-search-outline",
"localIcon": null,
"order": 2,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "infra",
"pageType": "leaf",
"source": "generated"
},
{
"name": "infra_rd-code",
"path": "/infra/rd-code",
@@ -710,7 +743,7 @@
"i18nKey": "route.infra_rd-code",
"icon": "mdi:identifier",
"localIcon": null,
"order": 2,
"order": 3,
"hideInMenu": false,
"keepAlive": true,
"activeMenu": null,
@@ -723,7 +756,7 @@
"i18nKey": "route.infra_rd-code",
"icon": "mdi:identifier",
"localIcon": null,
"order": 2,
"order": 3,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,

View File

@@ -82,6 +82,12 @@ function handleConfirm() {
footer-class="business-form-dialog__footer"
v-bind="$attrs"
>
<template #header="{ close, titleId, titleClass }">
<slot name="title" :close="close" :title="props.title" :title-id="titleId" :title-class="titleClass">
<span :id="titleId" :class="titleClass">{{ props.title }}</span>
</slot>
</template>
<ElScrollbar
v-if="props.scrollbar"
:max-height="props.maxBodyHeight"

View File

@@ -12,7 +12,9 @@ defineProps<Props>();
<template>
<section class="business-form-section">
<h4 class="business-form-section__title">{{ title }}</h4>
<h4 class="business-form-section__title">
<slot name="title">{{ title }}</slot>
</h4>
<slot />
</section>
</template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'SubordinateSelector' });
interface Props {
@@ -17,6 +19,41 @@ const selectedUserId = defineModel<string | null>('selectedUserId', {
default: null
});
function sortSubordinateNodes(
nodes: Api.SystemManage.MySubordinateTreeNode[] | null | undefined
): Api.SystemManage.MySubordinateTreeNode[] | null {
if (!nodes?.length) return null;
return nodes
.map((node, index) => ({
...node,
children: sortSubordinateNodes(node.children),
originalIndex: index
}))
.sort((left, right) => {
const leftHasChildren = (left.children?.length ?? 0) > 0 ? 1 : 0;
const rightHasChildren = (right.children?.length ?? 0) > 0 ? 1 : 0;
if (leftHasChildren !== rightHasChildren) {
return rightHasChildren - leftHasChildren;
}
return left.originalIndex - right.originalIndex;
})
.map(({ originalIndex: _ignored, ...node }) => node);
}
const treeData = computed<Api.SystemManage.MySubordinateTreeNode[] | null>(() => {
if (!props.data) return null;
return [
{
...props.data,
children: sortSubordinateNodes(props.data.children)
}
];
});
function handleNodeClick(node: Api.SystemManage.MySubordinateTreeNode) {
selectedUserId.value = node.userId;
}
@@ -40,7 +77,7 @@ function renderNodeLabel(node: Api.SystemManage.MySubordinateTreeNode) {
<ElEmpty v-if="!props.data" :image-size="88" :description="props.emptyText" />
<ElTree
v-else
:data="[props.data]"
:data="treeData || []"
node-key="userId"
:current-node-key="selectedUserId || undefined"
:props="{ label: 'userNickname', children: 'children' }"

View File

@@ -128,3 +128,51 @@ export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';
* 显示名与颜色hex均走字典前端按 level 取色不硬编码。
*/
export const NOTIFY_MESSAGE_LEVEL_DICT_CODE = 'notify_message_level';
/**
* 系统用户类型字典编码
*
* 对应业务字段:系统日志中的 userType
* 来源口径:后端 DictTypeConstants.USER_TYPE = user_type
*/
export const SYSTEM_USER_TYPE_DICT_CODE = 'user_type';
/**
* 系统登录日志类型字典编码
*
* 对应业务字段:登录日志中的 logType
* 来源口径:后端 DictTypeConstants.LOGIN_TYPE = system_login_type
*/
export const SYSTEM_LOGIN_TYPE_DICT_CODE = 'system_login_type';
/**
* 系统登录结果字典编码
*
* 对应业务字段:登录日志中的 result
* 来源口径:后端 DictTypeConstants.LOGIN_RESULT = system_login_result
*/
export const SYSTEM_LOGIN_RESULT_DICT_CODE = 'system_login_result';
/**
* 基础设施操作分类字典编码
*
* 对应业务字段API 访问日志中的 operateType
* 来源口径:后端 DictTypeConstants.OPERATE_TYPE = infra_operate_type
*/
export const INFRA_OPERATE_TYPE_DICT_CODE = 'infra_operate_type';
/**
* API 错误日志处理状态字典编码
*
* 对应业务字段API 错误日志中的 processStatus
* 来源口径:后端 DictTypeConstants.API_ERROR_LOG_PROCESS_STATUS = infra_api_error_log_process_status
*/
export const INFRA_API_ERROR_LOG_PROCESS_STATUS_DICT_CODE = 'infra_api_error_log_process_status';
/**
* 系统请求方式字典编码
*
* 对应业务字段:操作日志中的 requestMethod
* 来源口径:用户明确指定请求方式下拉来自运行时字典 system_request_method
*/
export const SYSTEM_REQUEST_METHOD_DICT_CODE = 'system_request_method';

View File

@@ -179,6 +179,11 @@ const local: App.I18n.Schema = {
'personal-center_pending-approval': 'Pending Approval',
infra: 'Infra',
'infra_state-machine': 'State Machine',
'infra_log-management': 'Log Management',
'infra_log-management_login-log': 'Login Log',
'infra_log-management_operate-log': 'Operate Log',
'infra_log-management_api-access-log': 'API Access Log',
'infra_log-management_api-error-log': 'API Error Log',
'infra_rd-code': 'R&D Code',
product: 'Product',
product_list: 'Product List',

View File

@@ -179,6 +179,11 @@ const local: App.I18n.Schema = {
'personal-center_pending-approval': '待我审批',
infra: '基础设施',
'infra_state-machine': '状态机管理',
'infra_log-management': '日志管理',
'infra_log-management_login-log': '登录日志',
'infra_log-management_operate-log': '操作日志',
'infra_log-management_api-access-log': 'API访问日志',
'infra_log-management_api-error-log': 'API错误日志',
'infra_rd-code': '研发令号',
product: '产品管理',
product_list: '产品列表',

View File

@@ -20,6 +20,11 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
500: () => import("@/views/_builtin/500/index.vue"),
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
login: () => import("@/views/_builtin/login/index.vue"),
"infra_log-management_api-access-log": () => import("@/views/infra/log-management/api-access-log/index.vue"),
"infra_log-management_api-error-log": () => import("@/views/infra/log-management/api-error-log/index.vue"),
"infra_log-management": () => import("@/views/infra/log-management/index.vue"),
"infra_log-management_login-log": () => import("@/views/infra/log-management/login-log/index.vue"),
"infra_log-management_operate-log": () => import("@/views/infra/log-management/operate-log/index.vue"),
"infra_rd-code": () => import("@/views/infra/rd-code/index.vue"),
"infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),

View File

@@ -63,6 +63,64 @@ export const generatedRoutes: GeneratedRoute[] = [
order: 20
},
children: [
{
name: 'infra_log-management',
path: '/infra/log-management',
component: 'view.infra_log-management',
meta: {
title: 'infra_log-management',
i18nKey: 'route.infra_log-management',
icon: 'mdi:text-box-search-outline',
order: 2,
keepAlive: true
},
children: [
{
name: 'infra_log-management_api-access-log',
path: '/infra/log-management/api-access-log',
component: 'view.infra_log-management_api-access-log',
meta: {
title: 'infra_log-management_api-access-log',
i18nKey: 'route.infra_log-management_api-access-log',
hideInMenu: true,
activeMenu: 'infra_log-management'
}
},
{
name: 'infra_log-management_api-error-log',
path: '/infra/log-management/api-error-log',
component: 'view.infra_log-management_api-error-log',
meta: {
title: 'infra_log-management_api-error-log',
i18nKey: 'route.infra_log-management_api-error-log',
hideInMenu: true,
activeMenu: 'infra_log-management'
}
},
{
name: 'infra_log-management_login-log',
path: '/infra/log-management/login-log',
component: 'view.infra_log-management_login-log',
meta: {
title: 'infra_log-management_login-log',
i18nKey: 'route.infra_log-management_login-log',
hideInMenu: true,
activeMenu: 'infra_log-management'
}
},
{
name: 'infra_log-management_operate-log',
path: '/infra/log-management/operate-log',
component: 'view.infra_log-management_operate-log',
meta: {
title: 'infra_log-management_operate-log',
i18nKey: 'route.infra_log-management_operate-log',
hideInMenu: true,
activeMenu: 'infra_log-management'
}
}
]
},
{
name: 'infra_rd-code',
path: '/infra/rd-code',
@@ -71,7 +129,7 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'infra_rd-code',
i18nKey: 'route.infra_rd-code',
icon: 'mdi:identifier',
order: 2,
order: 3,
keepAlive: true
}
},

View File

@@ -172,6 +172,11 @@ const routeMap: RouteMap = {
"500": "/500",
"iframe-page": "/iframe-page/:url",
"infra": "/infra",
"infra_log-management": "/infra/log-management",
"infra_log-management_api-access-log": "/infra/log-management/api-access-log",
"infra_log-management_api-error-log": "/infra/log-management/api-error-log",
"infra_log-management_login-log": "/infra/log-management/login-log",
"infra_log-management_operate-log": "/infra/log-management/operate-log",
"infra_rd-code": "/infra/rd-code",
"infra_state-machine": "/infra/state-machine",
"login": "/login/:module(pwd-login|reset-pwd)?",

View File

@@ -8,6 +8,6 @@ export function createDocumentTitleGuard(router: Router) {
const documentTitle = i18nKey ? $t(i18nKey) : title;
useTitle(documentTitle);
useTitle(`研发管理系统 - ${documentTitle}`);
});
}

View File

@@ -14,4 +14,5 @@ export * from './project-group';
export * from './project-shared';
export * from './route';
export * from './system-manage';
export * from './system-log';
export * from './work-report';

View File

@@ -0,0 +1,260 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
const LOGIN_LOG_PREFIX = `${SYSTEM_SERVICE_PREFIX}/login-log`;
const OPERATE_LOG_PREFIX = `${SYSTEM_SERVICE_PREFIX}/operate-log`;
const API_ACCESS_LOG_PREFIX = `${SYSTEM_SERVICE_PREFIX}/api-access-log`;
const API_ERROR_LOG_PREFIX = `${SYSTEM_SERVICE_PREFIX}/api-error-log`;
type StringIdResponse = string | number;
type LoginLogResponse = Omit<Api.SystemLog.Login.Log, 'id' | 'userId'> & {
id: StringIdResponse;
userId?: StringIdResponse | null;
};
type OperateLogResponse = Omit<Api.SystemLog.Operate.Log, 'id' | 'userId' | 'bizId'> & {
id: StringIdResponse;
userId: StringIdResponse;
bizId?: StringIdResponse | null;
};
type ApiAccessLogResponse = Omit<Api.SystemLog.ApiAccess.Log, 'id' | 'userId'> & {
id: StringIdResponse;
userId: StringIdResponse;
};
type ApiErrorLogResponse = Omit<Api.SystemLog.ApiError.Log, 'id' | 'userId' | 'processUserId'> & {
id: StringIdResponse;
userId: StringIdResponse;
processUserId?: StringIdResponse | null;
};
type LoginLogPageResponse = Api.SystemLog.Common.PageResult<LoginLogResponse>;
type OperateLogPageResponse = Api.SystemLog.Common.PageResult<OperateLogResponse>;
type ApiAccessLogPageResponse = Api.SystemLog.Common.PageResult<ApiAccessLogResponse>;
type ApiErrorLogPageResponse = Api.SystemLog.Common.PageResult<ApiErrorLogResponse>;
function appendValue(query: URLSearchParams, key: string, value: unknown) {
if (value === null || value === undefined || value === '') return;
if (Array.isArray(value)) {
value.forEach(item => appendValue(query, key, item));
return;
}
query.append(key, String(value));
}
function buildQuery(params: Record<string, unknown> = {}) {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
appendValue(query, key, value);
});
return query.toString();
}
function normalizeLoginLog(log: LoginLogResponse): Api.SystemLog.Login.Log {
return {
...log,
id: normalizeStringId(log.id),
userId: normalizeNullableStringId(log.userId),
traceId: log.traceId ?? null,
userType: log.userType ?? null,
userAgent: log.userAgent ?? null
};
}
function normalizeOperateLog(log: OperateLogResponse): Api.SystemLog.Operate.Log {
return {
...log,
id: normalizeStringId(log.id),
userId: normalizeStringId(log.userId),
bizId: normalizeNullableStringId(log.bizId),
traceId: log.traceId ?? null,
action: log.action ?? null,
extra: log.extra ?? null
};
}
function normalizeApiAccessLog(log: ApiAccessLogResponse): Api.SystemLog.ApiAccess.Log {
return {
...log,
id: normalizeStringId(log.id),
userId: normalizeStringId(log.userId),
traceId: log.traceId ?? null,
requestParams: log.requestParams ?? null,
responseBody: log.responseBody ?? null,
operateModule: log.operateModule ?? null,
operateName: log.operateName ?? null,
operateType: log.operateType ?? null,
resultMsg: log.resultMsg ?? null
};
}
function normalizeApiErrorLog(log: ApiErrorLogResponse): Api.SystemLog.ApiError.Log {
return {
...log,
id: normalizeStringId(log.id),
userId: normalizeStringId(log.userId),
traceId: log.traceId ?? null,
requestParams: log.requestParams ?? null,
exceptionRootCauseMessage: log.exceptionRootCauseMessage ?? null,
exceptionStackTrace: log.exceptionStackTrace ?? null,
exceptionClassName: log.exceptionClassName ?? null,
exceptionFileName: log.exceptionFileName ?? null,
exceptionMethodName: log.exceptionMethodName ?? null,
exceptionLineNumber: log.exceptionLineNumber ?? null,
processTime: log.processTime ?? null,
processUserId: normalizeNullableStringId(log.processUserId)
};
}
export async function fetchGetLoginLogPage(params?: Api.SystemLog.Login.SearchParams) {
const query = buildQuery((params ?? {}) as Record<string, unknown>);
const result = await request<LoginLogPageResponse>({
...safeJsonRequestConfig,
url: query ? `${LOGIN_LOG_PREFIX}/page?${query}` : `${LOGIN_LOG_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<LoginLogPageResponse>, data => ({
...data,
list: data.list.map(normalizeLoginLog)
}));
}
export async function fetchGetLoginLog(id: string) {
const result = await request<LoginLogResponse>({
...safeJsonRequestConfig,
url: `${LOGIN_LOG_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<LoginLogResponse>, normalizeLoginLog);
}
export function fetchExportLoginLog(params: Api.SystemLog.Login.SearchParams = {}) {
const query = buildQuery(params as Record<string, unknown>);
return request<Blob, 'blob'>({
url: query ? `${LOGIN_LOG_PREFIX}/export-excel?${query}` : `${LOGIN_LOG_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}
export async function fetchGetOperateLogPage(params?: Api.SystemLog.Operate.SearchParams) {
const query = buildQuery((params ?? {}) as Record<string, unknown>);
const result = await request<OperateLogPageResponse>({
...safeJsonRequestConfig,
url: query ? `${OPERATE_LOG_PREFIX}/page?${query}` : `${OPERATE_LOG_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<OperateLogPageResponse>, data => ({
...data,
list: data.list.map(normalizeOperateLog)
}));
}
export async function fetchGetOperateLog(id: string) {
const result = await request<OperateLogResponse>({
...safeJsonRequestConfig,
url: `${OPERATE_LOG_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<OperateLogResponse>, normalizeOperateLog);
}
export function fetchExportOperateLog(params: Api.SystemLog.Operate.SearchParams = {}) {
const query = buildQuery(params as Record<string, unknown>);
return request<Blob, 'blob'>({
url: query ? `${OPERATE_LOG_PREFIX}/export-excel?${query}` : `${OPERATE_LOG_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}
export async function fetchGetApiAccessLogPage(params?: Api.SystemLog.ApiAccess.SearchParams) {
const query = buildQuery((params ?? {}) as Record<string, unknown>);
const result = await request<ApiAccessLogPageResponse>({
...safeJsonRequestConfig,
url: query ? `${API_ACCESS_LOG_PREFIX}/page?${query}` : `${API_ACCESS_LOG_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ApiAccessLogPageResponse>, data => ({
...data,
list: data.list.map(normalizeApiAccessLog)
}));
}
export async function fetchGetApiAccessLog(id: string) {
const result = await request<ApiAccessLogResponse>({
...safeJsonRequestConfig,
url: `${API_ACCESS_LOG_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<ApiAccessLogResponse>, normalizeApiAccessLog);
}
export function fetchExportApiAccessLog(params: Api.SystemLog.ApiAccess.SearchParams = {}) {
const query = buildQuery(params as Record<string, unknown>);
return request<Blob, 'blob'>({
url: query ? `${API_ACCESS_LOG_PREFIX}/export-excel?${query}` : `${API_ACCESS_LOG_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}
export async function fetchGetApiErrorLogPage(params?: Api.SystemLog.ApiError.SearchParams) {
const query = buildQuery((params ?? {}) as Record<string, unknown>);
const result = await request<ApiErrorLogPageResponse>({
...safeJsonRequestConfig,
url: query ? `${API_ERROR_LOG_PREFIX}/page?${query}` : `${API_ERROR_LOG_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ApiErrorLogPageResponse>, data => ({
...data,
list: data.list.map(normalizeApiErrorLog)
}));
}
export async function fetchGetApiErrorLog(id: string) {
const result = await request<ApiErrorLogResponse>({
...safeJsonRequestConfig,
url: `${API_ERROR_LOG_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<ApiErrorLogResponse>, normalizeApiErrorLog);
}
export function fetchExportApiErrorLog(params: Api.SystemLog.ApiError.SearchParams = {}) {
const query = buildQuery(params as Record<string, unknown>);
return request<Blob, 'blob'>({
url: query ? `${API_ERROR_LOG_PREFIX}/export-excel?${query}` : `${API_ERROR_LOG_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}

View File

@@ -67,7 +67,7 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
const documentTitle = i18nKey ? $t(i18nKey) : title;
useTitle(documentTitle);
useTitle(`研发管理系统 - ${documentTitle}`);
}
function init() {

151
src/typings/api/system-log.d.ts vendored Normal file
View File

@@ -0,0 +1,151 @@
declare namespace Api {
/**
* namespace SystemLog
*
* backend api module: "system/*-log"
*/
namespace SystemLog {
namespace Common {
interface PageParams {
pageNo: number;
pageSize: number;
}
interface PageResult<T = any> {
total: number;
list: T[];
}
}
namespace Login {
interface Log {
id: string;
logType: number;
userId?: string | null;
userType?: number | null;
traceId?: string | null;
username: string;
result: number;
userIp: string;
userAgent?: string | null;
createTime: string;
}
type SearchParams = CommonType.RecordNullable<
Common.PageParams & {
userIp: string;
username: string;
status: boolean;
createTime: string[];
}
>;
}
namespace Operate {
interface Log {
id: string;
traceId?: string | null;
userId: string;
userName: string;
userType: number;
type: string;
subType: string;
bizId?: string | null;
action?: string | null;
extra?: string | null;
requestMethod: string;
requestUrl: string;
userIp: string;
userAgent: string;
createTime: string;
}
type SearchParams = CommonType.RecordNullable<
Common.PageParams & {
userId: string;
type: string;
requestMethod: string;
subType: string;
action: string;
createTime: string[];
}
>;
}
namespace ApiAccess {
interface Log {
id: string;
traceId?: string | null;
userId: string;
userType: number;
applicationName: string;
requestMethod: string;
requestUrl: string;
requestParams?: string | null;
responseBody?: string | null;
userIp: string;
userAgent: string;
operateModule?: string | null;
operateName?: string | null;
operateType?: number | null;
beginTime: string;
endTime: string;
duration: number;
resultCode: number;
resultMsg?: string | null;
createTime: string;
}
type SearchParams = CommonType.RecordNullable<
Common.PageParams & {
userId: string;
userType: number;
applicationName: string;
requestUrl: string;
beginTime: string[];
duration: number;
resultCode: number;
}
>;
}
namespace ApiError {
interface Log {
id: string;
traceId?: string | null;
userId: string;
userType: number;
applicationName: string;
requestMethod: string;
requestUrl: string;
requestParams?: string | null;
userIp: string;
userAgent: string;
exceptionTime: string;
exceptionName: string;
exceptionMessage: string;
exceptionRootCauseMessage?: string | null;
exceptionStackTrace?: string | null;
exceptionClassName?: string | null;
exceptionFileName?: string | null;
exceptionMethodName?: string | null;
exceptionLineNumber?: number | null;
processStatus: number;
processTime?: string | null;
processUserId?: string | null;
createTime: string;
}
type SearchParams = CommonType.RecordNullable<
Common.PageParams & {
userId: string;
userType: number;
applicationName: string;
requestUrl: string;
exceptionTime: string[];
processStatus: number;
}
>;
}
}
}

View File

@@ -26,6 +26,11 @@ declare module "@elegant-router/types" {
"500": "/500";
"iframe-page": "/iframe-page/:url";
"infra": "/infra";
"infra_log-management": "/infra/log-management";
"infra_log-management_api-access-log": "/infra/log-management/api-access-log";
"infra_log-management_api-error-log": "/infra/log-management/api-error-log";
"infra_log-management_login-log": "/infra/log-management/login-log";
"infra_log-management_operate-log": "/infra/log-management/operate-log";
"infra_rd-code": "/infra/rd-code";
"infra_state-machine": "/infra/state-machine";
"login": "/login/:module(pwd-login|reset-pwd)?";
@@ -138,6 +143,11 @@ declare module "@elegant-router/types" {
| "500"
| "iframe-page"
| "login"
| "infra_log-management_api-access-log"
| "infra_log-management_api-error-log"
| "infra_log-management"
| "infra_log-management_login-log"
| "infra_log-management_operate-log"
| "infra_rd-code"
| "infra_state-machine"
| "metrics_member-efficiency"

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>

View File

@@ -98,7 +98,7 @@ watch(visible, isVisible => {
<ElForm ref="formRef" :model="form" :rules="rules" label-position="top">
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="绩效月份">{{ props.rowData?.periodMonth || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="下属">{{ props.rowData?.employeeName || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="被考核人">{{ props.rowData?.employeeName || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="实际得分">{{ props.rowData?.actualScoreTotal ?? '--' }}</ElDescriptionsItem>
</ElDescriptions>

View File

@@ -44,7 +44,7 @@ export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOption
const { task } = payload;
const completeAction = options.resolveCompleteAction(task);
if (!completeAction) {
window.$message?.warning('当前任务暂无可用完成动作');
// window.$message?.warning('当前任务暂无可用完成动作');
return;
}

View File

@@ -11,6 +11,7 @@ import BusinessRichTextEditor from '@/components/custom/business-rich-text-edito
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import { SHOW_TASK_PARENT_FIELD } from '../shared';
import IconMdiAutoFix from '~icons/mdi/auto-fix';
defineOptions({ name: 'ProjectExecutionTaskOperateDialog' });
@@ -26,6 +27,7 @@ export interface PlannedEndShortcutOffset {
interface Props {
mode: OperateMode;
rowData: Api.Project.ProjectTask | null;
executionData?: Api.Project.ProjectExecution | null;
/** 创建模式下的父任务预填;编辑/查看模式忽略 */
defaultParentTaskId?: string | null;
userOptions: Api.SystemManage.UserSimple[];
@@ -40,6 +42,7 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
executionData: null,
defaultParentTaskId: null,
canManageAssignee: false,
plannedEndShortcuts: () => [
@@ -95,6 +98,8 @@ const dialogTitle = computed(() => {
return props.rowData?.taskTitle ? `编辑任务:${props.rowData.taskTitle}` : '编辑任务';
});
const canFillFromExecution = computed(() => props.mode === 'create' && Boolean(props.executionData));
const selectableParentTasks = computed(() => props.taskOptions.filter(item => item.id !== props.rowData?.id));
/** 编辑态 + 任务已开始statusCode 离开 pending→ 负责人锁定不可切换 */
@@ -108,11 +113,17 @@ const ownerLocked = computed(() => {
* owner 可能已不在执行协办人池里,用任务自带 ownerNickname 兜底回显,避免锁定态显示成裸 userId。
*/
const ownerSelectOptions = computed<Api.SystemManage.UserSimple[]>(() => {
const ownerId = props.rowData?.ownerId;
if (props.mode === 'create' || !ownerId || props.userOptions.some(item => item.id === ownerId)) {
const currentOwnerId = props.mode === 'create' ? model.ownerId : (props.rowData?.ownerId ?? null);
if (!currentOwnerId || props.userOptions.some(item => item.id === currentOwnerId)) {
return props.userOptions;
}
return [...props.userOptions, { id: ownerId, nickname: props.rowData?.ownerNickname || ownerId }];
const fallbackNickname =
props.mode === 'create'
? props.executionData?.ownerNickname || currentOwnerId
: props.rowData?.ownerNickname || currentOwnerId;
return [...props.userOptions, { id: currentOwnerId, nickname: fallbackNickname }];
});
/** 编辑态无协办人管理权限时只读回显create 恒可交互) */
@@ -129,7 +140,13 @@ const assigneeSelectOptions = computed(() => {
const options = props.userOptions.map(item => ({ id: item.id, nickname: item.nickname }));
const known = new Set(options.map(item => item.id));
if (props.mode !== 'create' && props.rowData) {
if (props.mode === 'create') {
const ownerId = model.ownerId;
if (ownerId && !known.has(ownerId)) {
options.push({ id: ownerId, nickname: props.executionData?.ownerNickname || ownerId });
known.add(ownerId);
}
} else if (props.rowData) {
props.rowData.assignees?.forEach(assignee => {
if (assignee.userId && !known.has(assignee.userId)) {
options.push({ id: assignee.userId, nickname: assignee.nickname || assignee.userId });
@@ -311,6 +328,23 @@ function handleAssigneeChange(value: string[]) {
model.assigneeUserIds = cleaned;
}
function fillFromExecution() {
const execution = props.executionData;
if (!execution) return;
model.taskTitle = execution.executionName || '';
model.ownerId = execution.ownerId || null;
model.plannedStartDate = execution.plannedStartDate || null;
model.plannedEndDate = execution.plannedEndDate || null;
model.priority = execution.priority || '3';
model.taskDesc = execution.executionDesc || null;
model.assigneeUserIds = props.userOptions.map(item => item.id);
nextTick(() => {
formRef.value?.clearValidate(['taskTitle', 'ownerId', 'plannedStartDate', 'plannedEndDate', 'priority']);
});
}
function applyBasicFieldsFromRow(row: Api.Project.ProjectTask | null) {
model.taskTitle = row?.taskTitle || '';
model.type = row?.type || '';
@@ -386,6 +420,19 @@ defineExpose({
<div class="task-operate-dialog__grid">
<div ref="leftColRef" class="task-operate-dialog__col-left">
<BusinessFormSection title="任务信息">
<template v-if="canFillFromExecution" #title>
<span class="task-operate-dialog__section-title">
<span>任务信息</span>
<ElTooltip content="一键带出当前执行信息" placement="top">
<ElButton text type="primary" class="task-operate-dialog__title-action" @click="fillFromExecution">
<template #icon>
<IconMdiAutoFix class="text-16px" />
</template>
</ElButton>
</ElTooltip>
</span>
</template>
<ElFormItem label="任务名称" prop="taskTitle">
<ElInput v-model="model.taskTitle" maxlength="200" placeholder="请输入任务名称" />
</ElFormItem>
@@ -526,6 +573,23 @@ defineExpose({
</template>
<style scoped>
.task-operate-dialog__section-title {
display: inline-flex;
align-items: center;
gap: 8px;
}
.task-operate-dialog__title-action {
width: 24px;
height: 24px;
padding: 0;
border-radius: 6px;
}
.task-operate-dialog__title-action:hover {
background-color: var(--el-color-primary-light-9);
}
.task-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;

View File

@@ -37,9 +37,7 @@ const isActiveAssignee = computed(() =>
const canSubmitWorklog = computed(() => {
if (!props.task || !currentUserId.value) return false;
if (!isOwner.value && !isActiveAssignee.value) return false;
return (
props.task.statusCode === 'pending' || props.task.statusCode === 'active' || props.task.statusCode === 'completed'
);
return props.task.statusCode === 'pending' || props.task.statusCode === 'active';
});
const records = ref<Api.Project.TaskWorklog[]>([]);

View File

@@ -52,6 +52,9 @@ const isOwner = computed(() => Boolean(props.taskOwnerId && props.taskOwnerId ==
const isView = computed(() => props.mode === 'view');
// 任务 completed 后填工时不回写进度§4.8.4 备注),此时进度字段只读,避免误导用户以为能改任务进度
const isProgressReadonly = computed(() => isView.value || props.taskStatusCode === 'completed');
// 任务 completed 时编辑模式:除工作内容和附件外,其他字段全部禁用
const isTaskCompleted = computed(() => props.taskStatusCode === 'completed');
const isFieldDisabled = computed(() => isView.value || isTaskCompleted.value);
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
@@ -373,7 +376,7 @@ defineExpose({
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="填报粒度" prop="granularity">
<ElSegmented v-model="model.granularity" :options="granularityOptions" :disabled="isView" />
<ElSegmented v-model="model.granularity" :options="granularityOptions" :disabled="isFieldDisabled" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
@@ -384,8 +387,8 @@ defineExpose({
type="date"
value-format="YYYY-MM-DD"
placeholder="选择工作日期"
:shortcuts="isView ? undefined : workDateShortcuts"
:disabled="isView"
:shortcuts="isFieldDisabled ? undefined : workDateShortcuts"
:disabled="isFieldDisabled"
class="task-worklog-form-dialog__date-picker"
/>
<ElTooltip v-else :content="weekRangeTooltip" :disabled="!weekRangeTooltip" placement="top">
@@ -395,8 +398,8 @@ defineExpose({
type="week"
format="YYYY[年第]ww[周]"
placeholder="选择工作周次"
:shortcuts="isView ? undefined : weekDateShortcuts"
:disabled="isView"
:shortcuts="isFieldDisabled ? undefined : weekDateShortcuts"
:disabled="isFieldDisabled"
class="task-worklog-form-dialog__date-picker"
/>
</span>
@@ -411,7 +414,7 @@ defineExpose({
:step="0.5"
:precision="1"
:placeholder="durationPlaceholder"
:disabled="isView"
:disabled="isFieldDisabled"
controls-position="right"
class="w-full"
/>
@@ -437,7 +440,7 @@ defineExpose({
v-model="model.difficulty"
:dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE"
placeholder="请选择完成难度"
:disabled="isView"
:disabled="isFieldDisabled"
show-remark
/>
</ElFormItem>

View File

@@ -262,7 +262,7 @@ function canEditRow(row: Api.Project.TaskWorklog) {
}
function canDeleteRow(row: Api.Project.TaskWorklog) {
return Boolean(isWorklogMutableStatus.value && currentUserId.value && row.userId === currentUserId.value);
return Boolean(props.taskStatusCode === 'active' && currentUserId.value && row.userId === currentUserId.value);
}
function formatHours(hours: number | null | undefined) {

View File

@@ -959,6 +959,7 @@ defineExpose({
v-model:visible="operateVisible"
:mode="operateMode"
:row-data="currentTask"
:execution-data="props.execution"
:default-parent-task-id="presetParentTaskId"
:user-options="executionAssigneeOptions"
:task-options="taskOptions"