feat(log、post、user、project、work-report): 添加用户姓名字段并实现岗位用户导出功能

- 在API访问日志、API错误日志中新增用户姓名字段显示
- 优化日志管理页面布局样式,修复高度自适应问题
- 添加系统岗位数据导出功能及相应API接口
- 添加系统用户数据导出功能及相应API接口
- 新增通用文件下载和导出文件名生成工具函数
- 修复工作报表删除确认提示文案
- 调整项目列表默认展开状态逻辑
- 完善岗位和用户管理页面导出按钮权限控制
- 更新日志实体类型定义,增加用户昵称字段
This commit is contained in:
dk
2026-07-01 17:15:12 +08:00
parent a650f6e96d
commit a8e2bac65a
15 changed files with 172 additions and 13 deletions

View File

@@ -18,6 +18,29 @@ const POST_PREFIX = `${SYSTEM_SERVICE_PREFIX}/post`;
const ORG_LEADER_PREFIX = `${SYSTEM_SERVICE_PREFIX}/org-leader`; const ORG_LEADER_PREFIX = `${SYSTEM_SERVICE_PREFIX}/org-leader`;
const USER_MANAGEMENT_RELATION_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user-management-relation`; const USER_MANAGEMENT_RELATION_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user-management-relation`;
function appendQueryValue(query: URLSearchParams, key: string, value: unknown) {
if (value === null || value === undefined || value === '') {
return;
}
if (Array.isArray(value)) {
value.forEach(item => appendQueryValue(query, key, item));
return;
}
query.append(key, String(value));
}
function buildQuery(params: object = {}) {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
appendQueryValue(query, key, value);
});
return query.toString();
}
function createRolePageQuery(params?: Api.SystemManage.RoleSearchParams) { function createRolePageQuery(params?: Api.SystemManage.RoleSearchParams) {
const query = new URLSearchParams(); const query = new URLSearchParams();
@@ -469,6 +492,17 @@ export function fetchBatchDeletePost(ids: number[]) {
}); });
} }
/** 导出岗位 */
export function fetchExportPost(params: Api.SystemManage.PostSearchParams) {
const query = buildQuery(params);
return request<Blob, 'blob'>({
url: query ? `${POST_PREFIX}/export-excel?${query}` : `${POST_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}
/** 获取用户简单列表(用于用户选择下拉框) */ /** 获取用户简单列表(用于用户选择下拉框) */
export async function fetchGetUserSimpleList() { export async function fetchGetUserSimpleList() {
return request<UserSimpleResponse[]>({ return request<UserSimpleResponse[]>({
@@ -579,6 +613,17 @@ export function fetchBatchDeleteUser(ids: number[]) {
}); });
} }
/** 导出用户 */
export function fetchExportUser(params: Api.SystemManage.UserSearchParams) {
const query = buildQuery(params);
return request<Blob, 'blob'>({
url: query ? `${USER_PREFIX}/export-excel?${query}` : `${USER_PREFIX}/export-excel`,
method: 'get',
responseType: 'blob'
});
}
/** 获取菜单列表 */ /** 获取菜单列表 */
export async function fetchGetMenuList(params?: Api.SystemManage.MenuSearchParams) { export async function fetchGetMenuList(params?: Api.SystemManage.MenuSearchParams) {
const result = await request<MenuResponse[]>({ const result = await request<MenuResponse[]>({

View File

@@ -77,6 +77,7 @@ declare namespace Api {
id: string; id: string;
traceId?: string | null; traceId?: string | null;
userId: string; userId: string;
userNickname?: string | null;
userType: number; userType: number;
applicationName: string; applicationName: string;
requestMethod: string; requestMethod: string;
@@ -114,6 +115,7 @@ declare namespace Api {
id: string; id: string;
traceId?: string | null; traceId?: string | null;
userId: string; userId: string;
userNickname?: string | null;
userType: number; userType: number;
applicationName: string; applicationName: string;
requestMethod: string; requestMethod: string;

View File

@@ -80,6 +80,7 @@ const detailSections: LogDetailSection[] = [
{ {
title: '业务上下文', title: '业务上下文',
fields: [ fields: [
{ label: '用户姓名', key: 'userNickname' },
{ label: '用户编号', key: 'userId' }, { label: '用户编号', key: 'userId' },
{ label: '操作模块', key: 'operateModule' }, { label: '操作模块', key: 'operateModule' },
{ label: '操作名', key: 'operateName' }, { label: '操作名', key: 'operateName' },
@@ -114,6 +115,13 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
columns: () => [ columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 }, { prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'applicationName', label: '应用名', minWidth: 140, showOverflowTooltip: true }, { prop: 'applicationName', label: '应用名', minWidth: 140, showOverflowTooltip: true },
{
prop: 'userNickname',
label: '用户姓名',
minWidth: 120,
showOverflowTooltip: true,
formatter: row => row.userNickname || '--'
},
{ prop: 'requestMethod', label: '请求方式', width: 100, align: 'center' }, { prop: 'requestMethod', label: '请求方式', width: 100, align: 'center' },
{ prop: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true }, { prop: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true },
{ prop: 'operateModule', label: '操作模块', minWidth: 140, showOverflowTooltip: true }, { prop: 'operateModule', label: '操作模块', minWidth: 140, showOverflowTooltip: true },
@@ -182,7 +190,7 @@ async function handleExport() {
</script> </script>
<template> <template>
<div class="flex-col-stretch gap-16px overflow-hidden"> <div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
<ApiAccessLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" /> <ApiAccessLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body"> <ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">

View File

@@ -71,6 +71,7 @@ const detailSections: LogDetailSection[] = [
{ {
title: '上下文', title: '上下文',
fields: [ fields: [
{ label: '用户姓名', key: 'userNickname' },
{ label: '用户编号', key: 'userId' }, { label: '用户编号', key: 'userId' },
{ label: '用户IP', key: 'userIp' }, { label: '用户IP', key: 'userIp' },
{ label: '浏览器UA', key: 'userAgent', type: 'multiline' }, { label: '浏览器UA', key: 'userAgent', type: 'multiline' },
@@ -109,6 +110,13 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
columns: () => [ columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 }, { prop: 'index', type: 'index', label: '序号', width: 64 },
{ prop: 'applicationName', label: '应用名', minWidth: 140, showOverflowTooltip: true }, { prop: 'applicationName', label: '应用名', minWidth: 140, showOverflowTooltip: true },
{
prop: 'userNickname',
label: '用户姓名',
minWidth: 120,
showOverflowTooltip: true,
formatter: row => row.userNickname || '--'
},
{ prop: 'requestMethod', label: '请求方式', width: 100, align: 'center' }, { prop: 'requestMethod', label: '请求方式', width: 100, align: 'center' },
{ prop: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true }, { prop: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true },
{ prop: 'exceptionName', label: '异常名', minWidth: 180, showOverflowTooltip: true }, { prop: 'exceptionName', label: '异常名', minWidth: 180, showOverflowTooltip: true },
@@ -174,7 +182,7 @@ async function handleExport() {
</script> </script>
<template> <template>
<div class="flex-col-stretch gap-16px overflow-hidden"> <div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
<ApiErrorLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" /> <ApiErrorLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body"> <ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">

View File

@@ -67,11 +67,13 @@ watch(
</ElCard> </ElCard>
<div v-if="activeTabMeta" class="log-management-page__content"> <div v-if="activeTabMeta" class="log-management-page__content">
<div class="log-management-page__pane">
<KeepAlive> <KeepAlive>
<component :is="componentMap[activeTab]" /> <component :is="componentMap[activeTab]" class="log-management-page__tab" />
</KeepAlive> </KeepAlive>
</div> </div>
</div> </div>
</div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -157,8 +159,26 @@ watch(
} }
.log-management-page__content { .log-management-page__content {
display: flex;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden;
}
.log-management-page__pane {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
:deep(.log-management-page__tab) {
display: flex;
width: 100%;
flex: 1;
min-width: 0;
min-height: 0;
} }
@media (width <= 1200px) { @media (width <= 1200px) {

View File

@@ -187,7 +187,7 @@ async function handleExport() {
</script> </script>
<template> <template>
<div class="flex-col-stretch gap-16px overflow-hidden"> <div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
<LoginLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" /> <LoginLogSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body"> <ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">

View File

@@ -183,7 +183,7 @@ async function handleExport() {
</script> </script>
<template> <template>
<div class="flex-col-stretch gap-16px overflow-hidden"> <div class="h-full min-h-0 flex-col-stretch gap-16px overflow-hidden">
<OperateLogSearch <OperateLogSearch
v-model:model="searchParams" v-model:model="searchParams"
:user-options="userOptions" :user-options="userOptions"

View File

@@ -331,7 +331,7 @@ async function handleSubmitReport(row: Api.WorkReport.Monthly.MonthlyReport) {
async function handleDelete(row: Api.WorkReport.Monthly.MonthlyReport) { async function handleDelete(row: Api.WorkReport.Monthly.MonthlyReport) {
try { try {
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', { await ElMessageBox.confirm(`确认删除该报告吗?`, '删除确认', {
type: 'warning', type: 'warning',
confirmButtonText: '确认', confirmButtonText: '确认',
cancelButtonText: '取消' cancelButtonText: '取消'

View File

@@ -298,7 +298,7 @@ async function handleSubmitReport(row: Api.WorkReport.Project.ProjectReport) {
async function handleDelete(row: Api.WorkReport.Project.ProjectReport) { async function handleDelete(row: Api.WorkReport.Project.ProjectReport) {
try { try {
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', { await ElMessageBox.confirm(`确认删除该报告吗?`, '删除确认', {
type: 'warning', type: 'warning',
confirmButtonText: '确认', confirmButtonText: '确认',
cancelButtonText: '取消' cancelButtonText: '取消'

View File

@@ -420,6 +420,11 @@ async function refreshDraft() {
if (result.error || !result.data) return; if (result.error || !result.data) return;
baseInfo.value = {
...(baseInfo.value || {}),
...result.data
} as WorkReportRow;
if (props.reportType === 'weekly') { if (props.reportType === 'weekly') {
applyWeeklyEditableFields(result.data as Api.WorkReport.Weekly.WeeklyReport); applyWeeklyEditableFields(result.data as Api.WorkReport.Weekly.WeeklyReport);
} else if (props.reportType === 'monthly') { } else if (props.reportType === 'monthly') {

View File

@@ -347,7 +347,7 @@ async function handleSubmitReport(row: Api.WorkReport.Weekly.WeeklyReport) {
async function handleDelete(row: Api.WorkReport.Weekly.WeeklyReport) { async function handleDelete(row: Api.WorkReport.Weekly.WeeklyReport) {
try { try {
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', { await ElMessageBox.confirm(`确认删除该报告吗?`, '删除确认', {
type: 'warning', type: 'warning',
confirmButtonText: '确认', confirmButtonText: '确认',
cancelButtonText: '取消' cancelButtonText: '取消'

View File

@@ -45,7 +45,7 @@ const searchParams = reactive(getInitSearchParams());
const navKey = ref<ProjectListNavKey>('active'); const navKey = ref<ProjectListNavKey>('active');
const groupPage = ref<Api.Project.ProjectGroupPageResult>(createEmptyGroupPage()); const groupPage = ref<Api.Project.ProjectGroupPageResult>(createEmptyGroupPage());
const loading = ref(false); const loading = ref(false);
const allCollapsed = ref(true); const allCollapsed = ref(false);
/** 状态看板项overview-summary items状态机动态下发左栏与"全部"口径派生均以此为源 */ /** 状态看板项overview-summary items状态机动态下发左栏与"全部"口径派生均以此为源 */
const statusBoardItems = ref<Api.Project.OverviewStatusItem[]>([]); const statusBoardItems = ref<Api.Project.OverviewStatusItem[]>([]);
/** "全部"口径总数(后端 total前端不自行求和 */ /** "全部"口径总数(后端 total前端不自行求和 */
@@ -171,7 +171,7 @@ async function handleResetSearch() {
async function handleNavChange(key: ProjectListNavKey) { async function handleNavChange(key: ProjectListNavKey) {
navKey.value = key; navKey.value = key;
allCollapsed.value = true; allCollapsed.value = false;
await Promise.all([loadOverviewData(), loadGroupPage(1)]); await Promise.all([loadOverviewData(), loadGroupPage(1)]);
} }

View File

@@ -4,9 +4,10 @@ import type { TableInstance } from 'element-plus';
import { ElButton, ElPopconfirm, ElTag } from 'element-plus'; import { ElButton, ElPopconfirm, ElTag } from 'element-plus';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useBoolean } from '@sa/hooks'; import { useBoolean } from '@sa/hooks';
import { fetchBatchDeletePost, fetchDeletePost, fetchGetPostPage } from '@/service/api'; import { fetchBatchDeletePost, fetchDeletePost, fetchExportPost, fetchGetPostPage } from '@/service/api';
import { useUIPaginatedTable } from '@/hooks/common/table'; import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell'; import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import { downloadBlob, getSystemExportFileName } from '@/views/system/shared/export';
import PostOperateDialog from './modules/post-operate-dialog.vue'; import PostOperateDialog from './modules/post-operate-dialog.vue';
import PostSearch from './modules/post-search.vue'; import PostSearch from './modules/post-search.vue';
@@ -74,6 +75,7 @@ function getPostTypeLabel(type?: Api.SystemManage.PostType | null) {
const searchParams = reactive(getInitSearchParams()); const searchParams = reactive(getInitSearchParams());
const postTableRef = ref<TableInstance>(); const postTableRef = ref<TableInstance>();
const postCheckedRowKeys = ref<number[]>([]); const postCheckedRowKeys = ref<number[]>([]);
const exporting = ref(false);
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
paginationProps: { paginationProps: {
@@ -222,6 +224,18 @@ async function reloadPostTable(page = searchParams.pageNo) {
postTableRef.value?.clearSelection(); postTableRef.value?.clearSelection();
} }
async function handleExport() {
exporting.value = true;
const { error, data: blob } = await fetchExportPost(searchParams);
exporting.value = false;
if (error || !blob) {
return;
}
downloadBlob(blob, getSystemExportFileName('岗位数据'));
}
function resetSearchParams() { function resetSearchParams() {
Object.assign(searchParams, getInitSearchParams()); Object.assign(searchParams, getInitSearchParams());
reloadPostTable(1); reloadPostTable(1);
@@ -255,6 +269,12 @@ function handleSubmitted(postId: number) {
@refresh="reloadPostTable" @refresh="reloadPostTable"
> >
<template #default> <template #default>
<ElButton v-auth="'system:post:export'" plain type="primary" :loading="exporting" @click="handleExport">
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
<ElButton plain type="primary" @click="openAdd"> <ElButton plain type="primary" @click="openAdd">
<template #icon> <template #icon>
<icon-ic-round-plus class="text-icon" /> <icon-ic-round-plus class="text-icon" />

View File

@@ -0,0 +1,16 @@
import dayjs from 'dayjs';
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 getSystemExportFileName(label: string) {
return `${label}_${dayjs().format('YYYY-MM-DD')}.xls`;
}

View File

@@ -9,6 +9,7 @@ import {
fetchBatchDeleteUser, fetchBatchDeleteUser,
fetchDeleteDept, fetchDeleteDept,
fetchDeleteUser, fetchDeleteUser,
fetchExportUser,
fetchGetDeptList, fetchGetDeptList,
fetchGetDictDataByCode, fetchGetDictDataByCode,
fetchGetPostSimpleList, fetchGetPostSimpleList,
@@ -23,6 +24,7 @@ import BusinessTableActionCell from '@/components/custom/business-table-action-c
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { $t } from '@/locales'; import { $t } from '@/locales';
import { buildMenuTree } from '@/views/system/shared/menu-tree'; import { buildMenuTree } from '@/views/system/shared/menu-tree';
import { downloadBlob, getSystemExportFileName } from '@/views/system/shared/export';
import UserManagementRelation from '@/views/system/user-management-relation/index.vue'; import UserManagementRelation from '@/views/system/user-management-relation/index.vue';
import UserOperateDialog from './modules/user-operate-dialog.vue'; import UserOperateDialog from './modules/user-operate-dialog.vue';
import UserOrgLeaderDialog from './modules/user-org-leader-dialog.vue'; import UserOrgLeaderDialog from './modules/user-org-leader-dialog.vue';
@@ -130,6 +132,7 @@ const deptLoading = ref(false);
const userTableRef = ref<TableInstance>(); const userTableRef = ref<TableInstance>();
const userCheckedRowKeys = ref<number[]>([]); const userCheckedRowKeys = ref<number[]>([]);
const statusLoadingIds = ref<number[]>([]); const statusLoadingIds = ref<number[]>([]);
const exporting = ref(false);
const deptList = ref<Api.SystemManage.Dept[]>([]); const deptList = ref<Api.SystemManage.Dept[]>([]);
const currentDeptId = ref<number | null>(null); const currentDeptId = ref<number | null>(null);
const operateVisible = ref(false); const operateVisible = ref(false);
@@ -392,6 +395,25 @@ async function reloadUserTable(page = searchParams.pageNo) {
userTableRef.value?.clearSelection(); userTableRef.value?.clearSelection();
} }
async function handleExport() {
if (!currentDeptId.value) {
return;
}
exporting.value = true;
const { error, data: blob } = await fetchExportUser({
...searchParams,
deptId: currentDeptId.value
});
exporting.value = false;
if (error || !blob) {
return;
}
downloadBlob(blob, getSystemExportFileName('用户数据'));
}
function handleDeptSelect(nodeData: Api.SystemManage.Dept) { function handleDeptSelect(nodeData: Api.SystemManage.Dept) {
currentDeptId.value = nodeData.id; currentDeptId.value = nodeData.id;
} }
@@ -669,6 +691,19 @@ onMounted(async () => {
</div> </div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadUserTable"> <TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadUserTable">
<template #default> <template #default>
<ElButton
v-auth="'system:user:export'"
plain
type="primary"
:loading="exporting"
:disabled="!currentDept"
@click="handleExport"
>
<template #icon>
<icon-mdi-download class="text-icon" />
</template>
导出
</ElButton>
<ElButton plain type="primary" :disabled="!currentDept" @click="openAdd"> <ElButton plain type="primary" :disabled="!currentDept" @click="openAdd">
<template #icon> <template #icon>
<icon-ic-round-plus class="text-icon" /> <icon-ic-round-plus class="text-icon" />