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 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) {
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() {
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) {
const result = await request<MenuResponse[]>({

View File

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

View File

@@ -80,6 +80,7 @@ const detailSections: LogDetailSection[] = [
{
title: '业务上下文',
fields: [
{ label: '用户姓名', key: 'userNickname' },
{ label: '用户编号', key: 'userId' },
{ label: '操作模块', key: 'operateModule' },
{ label: '操作名', key: 'operateName' },
@@ -114,6 +115,13 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ 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: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true },
{ prop: 'operateModule', label: '操作模块', minWidth: 140, showOverflowTooltip: true },
@@ -182,7 +190,7 @@ async function handleExport() {
</script>
<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" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">

View File

@@ -71,6 +71,7 @@ const detailSections: LogDetailSection[] = [
{
title: '上下文',
fields: [
{ label: '用户姓名', key: 'userNickname' },
{ label: '用户编号', key: 'userId' },
{ label: '用户IP', key: 'userIp' },
{ label: '浏览器UA', key: 'userAgent', type: 'multiline' },
@@ -109,6 +110,13 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
columns: () => [
{ prop: 'index', type: 'index', label: '序号', width: 64 },
{ 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: 'requestUrl', label: '请求地址', minWidth: 220, showOverflowTooltip: true },
{ prop: 'exceptionName', label: '异常名', minWidth: 180, showOverflowTooltip: true },
@@ -174,7 +182,7 @@ async function handleExport() {
</script>
<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" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">

View File

@@ -67,9 +67,11 @@ watch(
</ElCard>
<div v-if="activeTabMeta" class="log-management-page__content">
<KeepAlive>
<component :is="componentMap[activeTab]" />
</KeepAlive>
<div class="log-management-page__pane">
<KeepAlive>
<component :is="componentMap[activeTab]" class="log-management-page__tab" />
</KeepAlive>
</div>
</div>
</div>
</template>
@@ -157,8 +159,26 @@ watch(
}
.log-management-page__content {
display: flex;
flex: 1;
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) {

View File

@@ -187,7 +187,7 @@ async function handleExport() {
</script>
<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" />
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">

View File

@@ -183,7 +183,7 @@ async function handleExport() {
</script>
<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
v-model:model="searchParams"
: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) {
try {
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', {
await ElMessageBox.confirm(`确认删除该报告吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'

View File

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

View File

@@ -420,6 +420,11 @@ async function refreshDraft() {
if (result.error || !result.data) return;
baseInfo.value = {
...(baseInfo.value || {}),
...result.data
} as WorkReportRow;
if (props.reportType === 'weekly') {
applyWeeklyEditableFields(result.data as Api.WorkReport.Weekly.WeeklyReport);
} 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) {
try {
await ElMessageBox.confirm(`确认删除 ${formatPeriod(row)} 吗?`, '删除确认', {
await ElMessageBox.confirm(`确认删除该报告吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'

View File

@@ -45,7 +45,7 @@ const searchParams = reactive(getInitSearchParams());
const navKey = ref<ProjectListNavKey>('active');
const groupPage = ref<Api.Project.ProjectGroupPageResult>(createEmptyGroupPage());
const loading = ref(false);
const allCollapsed = ref(true);
const allCollapsed = ref(false);
/** 状态看板项overview-summary items状态机动态下发左栏与"全部"口径派生均以此为源 */
const statusBoardItems = ref<Api.Project.OverviewStatusItem[]>([]);
/** "全部"口径总数(后端 total前端不自行求和 */
@@ -171,7 +171,7 @@ async function handleResetSearch() {
async function handleNavChange(key: ProjectListNavKey) {
navKey.value = key;
allCollapsed.value = true;
allCollapsed.value = false;
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 dayjs from 'dayjs';
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 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 PostSearch from './modules/post-search.vue';
@@ -74,6 +75,7 @@ function getPostTypeLabel(type?: Api.SystemManage.PostType | null) {
const searchParams = reactive(getInitSearchParams());
const postTableRef = ref<TableInstance>();
const postCheckedRowKeys = ref<number[]>([]);
const exporting = ref(false);
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
paginationProps: {
@@ -222,6 +224,18 @@ async function reloadPostTable(page = searchParams.pageNo) {
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() {
Object.assign(searchParams, getInitSearchParams());
reloadPostTable(1);
@@ -255,6 +269,12 @@ function handleSubmitted(postId: number) {
@refresh="reloadPostTable"
>
<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">
<template #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,
fetchDeleteDept,
fetchDeleteUser,
fetchExportUser,
fetchGetDeptList,
fetchGetDictDataByCode,
fetchGetPostSimpleList,
@@ -23,6 +24,7 @@ import BusinessTableActionCell from '@/components/custom/business-table-action-c
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { $t } from '@/locales';
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 UserOperateDialog from './modules/user-operate-dialog.vue';
import UserOrgLeaderDialog from './modules/user-org-leader-dialog.vue';
@@ -130,6 +132,7 @@ const deptLoading = ref(false);
const userTableRef = ref<TableInstance>();
const userCheckedRowKeys = ref<number[]>([]);
const statusLoadingIds = ref<number[]>([]);
const exporting = ref(false);
const deptList = ref<Api.SystemManage.Dept[]>([]);
const currentDeptId = ref<number | null>(null);
const operateVisible = ref(false);
@@ -392,6 +395,25 @@ async function reloadUserTable(page = searchParams.pageNo) {
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) {
currentDeptId.value = nodeData.id;
}
@@ -669,6 +691,19 @@ onMounted(async () => {
</div>
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadUserTable">
<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">
<template #icon>
<icon-ic-round-plus class="text-icon" />