coder-common-thin-frontend/src/views/system/user/index.vue
Leo 6dc0b0a10c style(user): 优化用户管理页面操作列宽度,提升界面布局
- 调整操作列宽度从240px到280px,提供更好的按钮显示空间
- 优化按钮布局,确保操作按钮有足够的显示空间
- 提升表格整体视觉效果和用户体验
2025-07-09 10:18:56 +08:00

2257 lines
64 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="user-management p-1 bg-gray-50 h-screen flex flex-col">
<!-- 搜索表单和用户表格 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 flex-1 flex flex-col overflow-hidden">
<!-- 搜索表单 -->
<div class="px-4 py-2 border-b border-gray-100">
<n-form
ref="searchFormRef"
:model="searchForm"
label-placement="left"
label-width="auto"
class="search-form"
>
<n-grid :cols="5" :x-gap="8" :y-gap="4">
<n-grid-item>
<n-form-item label="登录账号" path="loginName">
<n-input
v-model:value="searchForm.loginName"
placeholder="请输入登录账号"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="用户姓名" path="userName">
<n-input
v-model:value="searchForm.userName"
placeholder="请输入用户姓名"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="手机号" path="phone">
<n-input
v-model:value="searchForm.phone"
placeholder="请输入手机号"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="创建时间" path="timeRange">
<n-date-picker
v-model:value="searchForm.timeRange"
type="datetimerange"
clearable
placeholder="选择时间范围"
:shortcuts="{
今天: () => [new Date().setHours(0, 0, 0, 0), new Date().setHours(23, 59, 59, 999)],
昨天: () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return [yesterday.setHours(0, 0, 0, 0), yesterday.setHours(23, 59, 59, 999)];
},
最近7天: () => [Date.now() - 7 * 24 * 60 * 60 * 1000, Date.now()],
最近30天: () => [Date.now() - 30 * 24 * 60 * 60 * 1000, Date.now()],
}"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="用户状态" path="userStatus">
<n-select
v-model:value="searchForm.userStatus"
placeholder="请选择用户状态"
clearable
:options="[
{ label: '启用', value: '0' },
{ label: '停用', value: '1' },
]"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="" path="">
<NSpace>
<NButton type="primary" @click="handleSearch">
<template #icon>
<NIcon><icon-park-outline:search /></NIcon>
</template>
搜索
</NButton>
<NButton @click="handleReset">
<template #icon>
<NIcon><icon-park-outline:refresh /></NIcon>
</template>
重置
</NButton>
</NSpace>
</n-form-item>
</n-grid-item>
</n-grid>
</n-form>
</div>
<!-- 表格头部操作栏 -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<div class="flex items-center gap-4">
<NButton v-button="PERMISSIONS.USER.ADD" type="primary" class="px-6 flex items-center" @click="handleAdd">
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:plus />
</NIcon>
</template>
新增
</NButton>
<NButton
v-button="PERMISSIONS.USER.DELETE"
type="error"
:disabled="selectedRows.length === 0"
class="px-3 flex items-center"
@click="handleBatchDelete"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:delete />
</NIcon>
</template>
删除
</NButton>
<NButton
v-button="PERMISSIONS.USER.ROLE"
type="info"
:disabled="selectedRows.length !== 1"
class="px-3 flex items-center"
@click="handleAssignRole(selectedRows[0])"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:userPositioning />
</NIcon>
</template>
分配角色
</NButton>
<NDropdown
v-button="PERMISSIONS.USER.EXPORT"
trigger="click"
:show="showExportDropdown"
:options="[
{
label: '导出当前查询数据',
key: 'current',
icon: () => h(NIcon, { size: 14 }, { default: () => h(IconParkOutlineDownload) }),
},
{
label: '导出全部数据',
key: 'all',
icon: () => h(NIcon, { size: 14 }, { default: () => h(IconParkOutlineDownloadOne) }),
},
{
type: 'divider',
},
{
label: '下载导入模板',
key: 'template',
icon: () => h(NIcon, { size: 14 }, { default: () => h(IconParkOutlineFileCodeOne) }),
},
]"
@update:show="(show: boolean) => showExportDropdown = show"
@select="handleExportMenuSelect"
>
<NButton class="px-6 flex items-center">
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:download />
</NIcon>
</template>
导出
<NIcon class="ml-1" style="transform: translateY(-1px)">
<icon-park-outline:down />
</NIcon>
</NButton>
</NDropdown>
<NButton v-button="PERMISSIONS.USER.IMPORT" class="px-6 flex items-center" @click="handleImport">
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:upload />
</NIcon>
</template>
导入
</NButton>
</div>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span>共 {{ pagination.itemCount }} 条</span>
<NButton text @click="getUserList">
<template #icon>
<NIcon><icon-park-outline:refresh /></NIcon>
</template>
</NButton>
</div>
</div>
<!-- 表格内容 -->
<div class="table-wrapper flex-1 overflow-auto pt-4">
<!-- 数据表格 -->
<n-data-table
v-if="tableData.length > 0 || loading"
:columns="columns"
:data="tableData"
:loading="loading"
:row-key="(row: UserVo) => row.userId"
:bordered="false"
:single-line="false"
:scroll-x="1600"
size="medium"
class="custom-table"
@update:checked-row-keys="handleRowSelectionChange"
/>
<!-- 空状态 -->
<CoiEmpty
v-else
:type="getEmptyType()"
:title="getEmptyTitle()"
:description="getEmptyDescription()"
:show-action="true"
:action-text="getEmptyActionText()"
size="medium"
@action="handleEmptyAction"
>
<template #action>
<NButton
type="primary"
size="medium"
round
class="coi-empty__action-btn"
@click="handleEmptyAction"
>
<template #icon>
<NIcon>
<icon-park-outline:refresh v-if="hasSearchConditions()" />
<icon-park-outline:plus v-else />
</NIcon>
</template>
{{ getEmptyActionText() }}
</NButton>
</template>
</CoiEmpty>
</div>
<!-- 分页器 -->
<div v-if="tableData.length > 0" class="border-t border-gray-100">
<CoiPagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:show-size-picker="true"
:page-sizes="[10, 20, 50, 100]"
:show-quick-jumper="true"
:show-current-info="true"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<!-- 用户表单弹框 -->
<CoiDialog
ref="userDialogRef"
:title="modalTitle"
:width="800"
height="auto"
confirm-text="确定"
cancel-text="取消"
@coi-confirm="handleSubmit"
@coi-cancel="handleCancel"
>
<template #content>
<div class="px-3 py-2">
<n-form
ref="formRef"
:model="formData"
:rules="rules"
label-placement="left"
label-width="100px"
require-mark-placement="right-hanging"
>
<n-grid :cols="2" :x-gap="24">
<n-grid-item>
<n-form-item label="登录账号" path="loginName">
<n-input
v-model:value="formData.loginName"
placeholder="请输入登录账号"
:disabled="isEdit"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="用户姓名" path="userName">
<n-input
v-model:value="formData.userName"
placeholder="请输入用户姓名"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="用户类型" path="userType">
<n-select
v-model:value="formData.userType"
placeholder="请选择用户类型"
:options="[
{ label: '系统用户', value: '1' },
{ label: '注册用户', value: '2' },
{ label: '微信用户', value: '3' },
]"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="性别" path="sex">
<n-select
v-model:value="formData.sex"
placeholder="请选择性别"
:options="[
{ label: '男', value: '1' },
{ label: '女', value: '2' },
{ label: '未知', value: '3' },
]"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="邮箱" path="email">
<n-input
v-model:value="formData.email"
placeholder="请输入邮箱"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="手机号" path="phone">
<n-input
v-model:value="formData.phone"
placeholder="请输入手机号"
/>
</n-form-item>
</n-grid-item>
<n-grid-item v-if="!isEdit">
<n-form-item label="密码" path="password">
<n-input
v-model:value="formData.password"
type="password"
placeholder="请输入密码"
show-password-on="click"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="用户状态" path="userStatus">
<n-radio-group v-model:value="formData.userStatus">
<n-radio value="0">
启用
</n-radio>
<n-radio value="1">
停用
</n-radio>
</n-radio-group>
</n-form-item>
</n-grid-item>
<n-grid-item v-if="roleOptions.length > 0" :span="2">
<n-form-item label="角色分配" path="roleIds">
<n-select
v-model:value="formData.roleIds"
multiple
placeholder="请选择角色"
class="role-select"
:render-tag="renderRoleTag"
:options="roleOptions.map(role => ({
label: role.roleName,
value: role.roleId,
}))"
/>
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="备注" path="remark">
<n-input
v-model:value="formData.remark"
type="textarea"
placeholder="请输入备注信息"
:rows="3"
/>
</n-form-item>
</n-form>
</div>
</template>
</CoiDialog>
<!-- 角色分配弹框 -->
<CoiDialog
ref="roleDialogRef"
:title="roleModalTitle"
:width="600"
height="auto"
confirm-text="确定"
cancel-text="取消"
@coi-confirm="handleConfirmAssignRole"
@coi-cancel="handleCancelAssignRole"
>
<template #content>
<div class="p-3 space-y-4">
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold">
{{ currentAssignUser?.loginName?.charAt(0).toUpperCase() }}
</div>
<div>
<div class="font-medium text-gray-900">
{{ currentAssignUser?.userName }}
</div>
<div class="text-sm text-gray-500">
{{ currentAssignUser?.loginName }}
</div>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">选择角色</label>
<div v-if="roleLoading" class="text-center text-gray-500 p-8">
<n-spin size="small" />
<p class="mt-2">
正在加载角色列表...
</p>
</div>
<n-transfer
v-else-if="availableRoles.length > 0"
v-model:value="selectedRoleIds"
:options="availableRoles"
source-title="可选角色"
target-title="已选角色"
:filterable="true"
:show-selected="true"
class="role-transfer"
/>
<div v-else class="text-center text-gray-500 p-8">
<div class="text-2xl text-gray-400 mb-2">
</div>
<p class="mt-2">
暂无可分配的角色
</p>
</div>
</div>
</div>
</template>
</CoiDialog>
<!-- 重置密码弹框 -->
<CoiDialog
ref="resetPwdDialogRef"
title="重置用户密码"
:width="500"
height="auto"
confirm-text="确认重置"
cancel-text="取消"
@coi-confirm="handleConfirmResetPassword"
@coi-cancel="handleCancelResetPassword"
>
<template #content>
<div class="p-3 space-y-4">
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-r from-orange-400 to-red-500 flex items-center justify-center text-white text-sm font-bold">
{{ currentResetUser?.loginName?.charAt(0).toUpperCase() }}
</div>
<div>
<div class="font-medium text-gray-900">
{{ currentResetUser?.userName }}
</div>
<div class="text-sm text-gray-500">
{{ currentResetUser?.loginName }}
</div>
</div>
</div>
</div>
<n-form
ref="resetPwdFormRef"
:model="resetPwdForm"
:rules="resetPwdRules"
label-placement="left"
label-width="80px"
require-mark-placement="right-hanging"
>
<n-form-item label="新密码" path="newPassword">
<n-input
v-model:value="resetPwdForm.newPassword"
type="password"
placeholder="请输入新密码(最少6位)"
show-password-on="click"
/>
</n-form-item>
<n-form-item label="确认密码" path="confirmPassword">
<n-input
v-model:value="resetPwdForm.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password-on="click"
/>
</n-form-item>
</n-form>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-start gap-2">
<div class="text-blue-500 text-sm mt-0.5">
</div>
<div class="text-blue-700 text-sm">
<p class="font-medium mb-1">
温馨提示:
</p>
<ul class="list-disc list-inside space-y-1 text-xs">
<li>密码长度至少6位字符</li>
<li>重置后用户需要使用新密码登录</li>
<li>建议提醒用户及时修改密码</li>
</ul>
</div>
</div>
</div>
</div>
</template>
</CoiDialog>
<!-- 头像查看弹框 -->
<CoiDialog
ref="avatarDialogRef"
:width="600"
height="auto"
cancel-text="关闭"
:show-confirm="false"
@coi-cancel="handleCloseAvatar"
>
<template #header>
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-gradient-to-r from-purple-400 to-pink-400 flex items-center justify-center text-white text-sm font-bold">
{{ currentAvatarUser?.loginName?.charAt(0).toUpperCase() }}
</div>
<div>
<span class="text-lg font-semibold">{{ currentAvatarUser?.userName }}</span>
<span class="text-sm text-gray-500 ml-2">({{ currentAvatarUser?.loginName }})</span>
</div>
</div>
</template>
<template #content>
<div class="flex flex-col items-center space-y-4 p-3">
<div class="relative">
<img
:src="currentAvatar"
:alt="`${currentAvatarUser?.userName}的头像`"
class="w-80 h-80 object-cover rounded-lg shadow-lg border-4 border-gray-100"
@error="handleAvatarError"
>
<div class="absolute -bottom-2 -right-2 bg-white rounded-full p-2 shadow-lg">
<div class="w-3 h-3 bg-green-500 rounded-full" />
</div>
</div>
<div class="text-center space-y-2">
<p class="text-gray-600 text-sm">
{{ currentAvatarUser?.avatar ? '用户头像' : '默认头像' }}
</p>
<div class="flex items-center justify-center gap-4 text-xs text-gray-400">
<span>用户类型: {{
currentAvatarUser?.userType === '1' ? '系统用户'
: currentAvatarUser?.userType === '2' ? '注册用户' : '微信用户'
}}</span>
<span>状态: {{ currentAvatarUser?.userStatus === '0' ? '启用' : '停用' }}</span>
</div>
</div>
</div>
</template>
</CoiDialog>
<!-- 导入用户弹框 -->
<CoiDialog
ref="importDialogRef"
title="导入用户数据"
:width="600"
height="auto"
confirm-text="开始导入"
cancel-text="取消"
@coi-confirm="handleConfirmImport"
@coi-cancel="handleCancelImport"
>
<template #content>
<div class="p-3 space-y-6">
<!-- 文件上传区域 -->
<div>
<h4 class="text-sm font-medium text-gray-700 mb-3">
选择Excel文件
</h4>
<NUpload
:default-file-list="[]"
:max="1"
accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
:on-before-upload="handleFileSelect"
:show-file-list="true"
:disabled="importLoading"
>
<NUploadDragger>
<div class="text-center">
<NIcon size="48" :depth="3" class="mb-2">
<icon-park-outline:file-excel />
</NIcon>
<div class="text-lg font-medium mb-1">
点击或拖拽上传Excel文件
</div>
<div class="text-sm text-gray-500">
支持 .xlsx 和 .xls 格式,文件大小不超过 10MB
</div>
</div>
</NUploadDragger>
</NUpload>
</div>
<!-- 导入选项 -->
<div>
<h4 class="text-sm font-medium text-gray-700 mb-3">
导入选项
</h4>
<NCheckbox
v-model:checked="updateSupport"
:disabled="importLoading"
>
覆盖已存在的用户数据
</NCheckbox>
<div class="text-xs text-gray-500 mt-1">
勾选后,如果导入的用户登录名已存在,将更新该用户的信息
</div>
</div>
<!-- 进度条 -->
<div v-if="importLoading">
<h4 class="text-sm font-medium text-gray-700 mb-3">
导入进度
</h4>
<NProgress
type="line"
:percentage="uploadProgress"
:show-indicator="true"
processing
/>
<div class="text-sm text-gray-500 mt-2 text-center">
正在导入用户数据,请稍候...
</div>
</div>
<!-- 说明 -->
<div class="text-xs text-gray-500 bg-gray-50 p-3 rounded">
<div class="font-medium mb-2">
导入说明:
</div>
<ul class="space-y-1">
<li>• 请使用系统提供的模板格式进行导入</li>
<li>• 登录账号为必填字段,且不能重复</li>
<li>• 导入的用户默认密码为 "123456"</li>
<li>• 用户状态默认为"启用"</li>
</ul>
</div>
<!-- 下载模板按钮 -->
<div class="flex justify-start">
<NButton
:disabled="importLoading"
@click="handleDownloadTemplate"
>
<template #icon>
<NIcon><icon-park-outline:download /></NIcon>
</template>
下载模板
</NButton>
</div>
</div>
</template>
</CoiDialog>
</div>
</template>
<script setup lang="ts">
import { h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NCheckbox, NDropdown, NIcon, NPopconfirm, NProgress, NSpace, NSwitch, NTag, NUpload, NUploadDragger } from 'naive-ui'
import IconParkOutlineEditOne from '~icons/icon-park-outline/edit-one'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlineRefresh from '~icons/icon-park-outline/refresh'
import IconParkOutlineUserPositioning from '~icons/icon-park-outline/user-positioning'
import IconParkOutlineDownload from '~icons/icon-park-outline/download'
import IconParkOutlineDownloadOne from '~icons/icon-park-outline/download-one'
import IconParkOutlineFileCodeOne from '~icons/icon-park-outline/file-code-one'
import CoiDialog from '@/components/common/CoiDialog.vue'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import {
addUser,
batchDeleteUsers,
deleteUser,
downloadExcelTemplate,
exportExcelData,
fetchUserPage,
getUserById,
importUserData,
resetUserPassword,
updateUser,
updateUserStatus,
} from '@/service/api/system/user'
import type { UserSearchForm, UserVo } from '@/service/api/system/user'
import {
assignUserRole,
fetchNormalRoleForUser,
fetchRoleList,
} from '@/service/api/system/role'
import type { RoleVo } from '@/service/api/system/role'
import { coiMsgBox, coiMsgError, coiMsgInfo, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { PERMISSIONS } from '@/constants/permissions'
import { usePermission } from '@/hooks/usePermission'
// 权限相关
const { hasButton } = usePermission()
// 响应式数据
const loading = ref(false)
const tableData = ref<UserVo[]>([])
const modalTitle = ref('新增用户')
const formRef = ref<FormInst | null>(null)
const searchFormRef = ref<FormInst | null>(null)
const isEdit = ref(false)
const currentUser = ref<UserVo | null>(null)
const selectedRows = ref<UserVo[]>([])
const roleOptions = ref<RoleVo[]>([])
// 弹框引用
const userDialogRef = ref()
const roleDialogRef = ref()
const resetPwdDialogRef = ref()
const avatarDialogRef = ref()
const importDialogRef = ref()
// 角色分配相关
const roleModalTitle = ref('分配角色')
const currentAssignUser = ref<UserVo | null>(null)
const availableRoles = ref<{ label: string, value: number }[]>([])
const selectedRoleIds = ref<number[]>([])
const roleLoading = ref(false)
// 重置密码相关
const resetPwdFormRef = ref<FormInst | null>(null)
const currentResetUser = ref<UserVo | null>(null)
const resetPwdForm = ref({
newPassword: '',
confirmPassword: '',
})
// 头像查看相关
const currentAvatar = ref('')
const currentAvatarUser = ref<UserVo | null>(null)
// 导入相关
const importLoading = ref(false)
const selectedFile = ref<File | null>(null)
const updateSupport = ref(false)
const uploadProgress = ref(0)
const progressInterval = ref<NodeJS.Timeout | null>(null)
const createdBlobUrls = ref<string[]>([])
// 分页数据
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
})
// 搜索表单数据
const searchForm = ref<UserSearchForm>({})
// 表单数据
const formData = ref({
loginName: '',
userName: '',
password: '',
confirmPassword: '',
userType: '1',
email: '',
phone: '',
sex: '3',
userStatus: '0',
remark: '',
roleIds: [] as number[],
})
// 表单验证规则
const rules = {
loginName: [
{ required: true, message: '请输入登录账号', trigger: 'blur' },
{ min: 3, max: 16, message: '账号长度为 3-16 位', trigger: 'blur' },
{ pattern: /^[a-z0-9]+$/i, message: '账号格式为数字以及字母', trigger: 'blur' },
],
userName: [
{ required: true, message: '请输入用户姓名', trigger: 'blur' },
],
email: [
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' },
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
],
userType: [
{ required: true, message: '请选择用户类型', trigger: 'change' },
],
userStatus: [
{ required: true, message: '请选择用户状态', trigger: 'change' },
],
}
// 重置密码表单验证规则
const resetPwdRules = {
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{
validator: (_rule: any, value: string) => {
if (value && value !== resetPwdForm.value.newPassword) {
return new Error('两次输入的密码不一致')
}
return true
},
trigger: 'blur',
},
],
}
// 表格列定义
const columns: DataTableColumns<UserVo> = [
{
type: 'selection',
width: 50,
},
{
title: '序号',
key: 'index',
width: 70,
align: 'center',
render: (_, index) => {
return (pagination.value.page - 1) * pagination.value.pageSize + index + 1
},
},
{
title: '头像',
key: 'avatar',
width: 80,
align: 'center',
render: (row) => {
return h('div', { class: 'flex justify-center' }, [
h('div', {
class: 'w-10 h-10 rounded-full cursor-pointer hover:scale-110 transition-transform duration-200 overflow-hidden border-2 border-gray-200 hover:border-blue-400',
onClick: () => handleViewAvatar(row),
}, [
row.avatar
? h('img', {
src: row.avatar,
alt: `${row.userName}的头像`,
class: 'w-full h-full object-cover',
onError: (e) => {
// 头像加载失败时显示默认头像
(e.target as HTMLElement).style.display = 'none'
const parent = (e.target as HTMLElement).parentElement
if (parent) {
parent.className = 'w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold shadow-md cursor-pointer hover:scale-110 transition-transform duration-200'
parent.textContent = row.loginName.charAt(0).toUpperCase()
}
},
})
: h('div', {
class: 'w-full h-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold',
}, row.loginName.charAt(0).toUpperCase()),
]),
])
},
},
{
title: '登录账号',
key: 'loginName',
width: 120,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.loginName)
},
},
{
title: '用户姓名',
key: 'userName',
width: 120,
align: 'center',
render: (row) => {
return h('div', { class: 'text-gray-600' }, row.userName)
},
},
{
title: '邮箱',
key: 'email',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'email-cell-text' }, row.email || '-')
},
},
{
title: '手机号',
key: 'phone',
align: 'center',
width: 130,
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.phone || '-')
},
},
{
title: '用户类型',
key: 'userType',
width: 100,
align: 'center',
render: (row) => {
const typeMap: Record<string, { label: string, type: any }> = {
1: { label: '系统用户', type: 'primary' },
2: { label: '注册用户', type: 'info' },
3: { label: '微信用户', type: 'warning' },
}
const config = typeMap[row.userType] || { label: '未知', type: 'default' }
return h(NTag, { type: config.type, size: 'small' }, { default: () => config.label })
},
},
{
title: '性别',
key: 'sex',
width: 80,
align: 'center',
render: (row) => {
const sexMap: Record<string, { label: string, icon: string, color: string }> = {
1: { label: '男', icon: '♂', color: 'text-blue-500' },
2: { label: '女', icon: '♀', color: 'text-pink-500' },
3: { label: '未知', icon: '?', color: 'text-gray-400' },
}
const config = sexMap[row.sex || '3']
return h('div', { class: `flex items-center justify-center gap-1 ${config.color}` }, [
h('span', { class: 'text-lg' }, config.icon),
h('span', { class: 'text-xs' }, config.label),
])
},
},
{
title: '用户状态',
key: 'userStatus',
width: 100,
align: 'center',
render: (row) => {
return h('div', { class: 'flex items-center justify-center' }, [
h(NPopconfirm, {
onPositiveClick: () => handleToggleStatus(row),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => `确定要${row.userStatus === '0' ? '停用' : '启用'}用户「${row.userName}」吗?`,
trigger: () => h(NSwitch, {
value: row.userStatus === '0',
size: 'small',
checkedChildren: '启用',
uncheckedChildren: '停用',
loading: false,
}),
}),
])
},
},
{
title: '登录时间',
key: 'loginTime',
width: 160,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-500 text-sm' }, row.loginTime || '-')
},
},
{
title: '操作',
key: 'actions',
width: 280,
align: 'center',
fixed: 'right',
render: (row) => {
const buttons = []
// 编辑按钮
if (hasButton(PERMISSIONS.USER.UPDATE)) {
buttons.push(h(NButton, {
type: 'primary',
size: 'small',
onClick: () => handleEdit(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineEditOne),
}),
default: () => '编辑',
}))
}
// 删除按钮
if (hasButton(PERMISSIONS.USER.DELETE)) {
buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleDelete(row.userId),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => '确定删除此用户吗?',
trigger: () => h(NButton, {
type: 'error',
size: 'small',
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineDelete),
}),
default: () => '删除',
}),
}))
}
// 重置密码按钮
if (hasButton(PERMISSIONS.USER.RESET_PWD)) {
buttons.push(h(NButton, {
type: 'warning',
size: 'small',
onClick: () => handleResetPassword(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineRefresh),
}),
default: () => '重置密码',
}))
}
// 分配角色按钮
if (hasButton(PERMISSIONS.USER.ROLE)) {
buttons.push(h(NButton, {
type: 'info',
size: 'small',
onClick: () => handleAssignRole(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineUserPositioning),
}),
default: () => '分配角色',
}))
}
return h('div', { class: 'flex items-center justify-center gap-1' }, buttons)
},
},
]
// 获取用户列表
async function getUserList() {
loading.value = true
try {
// 构建请求参数,处理时间范围
const { timeRange, ...otherParams } = searchForm.value
// 过滤掉空值和null值只保留有效的搜索条件
const filteredParams = Object.entries(otherParams).reduce((acc, [key, value]) => {
if (value !== null && value !== undefined && value !== '') {
acc[key] = value
}
return acc
}, {} as Record<string, any>)
const params = {
pageNo: pagination.value.page,
pageSize: pagination.value.pageSize,
...filteredParams,
}
// 处理时间范围转换为beginTime和endTime
if (timeRange && timeRange.length === 2) {
params.beginTime = new Date(timeRange[0]).toISOString().slice(0, 19).replace('T', ' ')
params.endTime = new Date(timeRange[1]).toISOString().slice(0, 19).replace('T', ' ')
}
const { isSuccess, data } = await fetchUserPage(params)
if (isSuccess && data) {
tableData.value = data.records || []
pagination.value.itemCount = data.total || 0
}
else {
coiMsgError('获取用户列表失败,请检查网络连接或联系管理员')
tableData.value = []
pagination.value.itemCount = 0
}
}
catch {
coiMsgError('获取用户列表失败,请检查网络连接')
tableData.value = []
pagination.value.itemCount = 0
}
finally {
loading.value = false
}
}
// 获取角色列表
async function getRoleList() {
try {
const { isSuccess, data } = await fetchRoleList()
if (isSuccess && data) {
roleOptions.value = data
}
}
catch {
}
}
// 分页变化处理
function handlePageChange(page: number) {
pagination.value.page = page
getUserList()
}
function handlePageSizeChange(pageSize: number) {
pagination.value.pageSize = pageSize
pagination.value.page = 1
getUserList()
}
// 搜索
function handleSearch() {
pagination.value.page = 1
getUserList()
}
// 处理键盘事件 (暂时未使用,保留用于后续扩展)
// function handleKeydown(event: KeyboardEvent) {
// if (event.key === 'Enter') {
// handleSearch()
// }
// }
// 重置搜索
async function handleReset() {
// 重置搜索表单的所有字段 - 明确清空每个字段
searchForm.value = {
loginName: '',
userName: '',
phone: '',
userStatus: null,
beginTime: '',
endTime: '',
timeRange: null,
}
// 使用 nextTick 确保 DOM 更新后再重置表单状态
await nextTick()
// 重置表单验证状态
if (searchFormRef.value) {
searchFormRef.value.restoreValidation()
}
// 重置分页到第一页
pagination.value.page = 1
// 重新获取数据
getUserList()
}
// 行选择变化
function handleRowSelectionChange(rowKeys: number[]) {
selectedRows.value = tableData.value.filter(row => rowKeys.includes(row.userId))
}
// 新增用户
function handleAdd() {
modalTitle.value = '新增用户'
isEdit.value = false
currentUser.value = null
formData.value = {
loginName: '',
userName: '',
password: '',
confirmPassword: '',
userType: '1',
email: '',
phone: '',
sex: '3',
userStatus: '0',
remark: '',
roleIds: [],
}
userDialogRef.value?.coiOpen()
}
// 编辑用户
async function handleEdit(user: UserVo) {
modalTitle.value = '编辑用户'
isEdit.value = true
currentUser.value = user
try {
// 获取完整的用户信息包含roleIds
const { isSuccess, data } = await getUserById(user.userId)
if (isSuccess && data) {
formData.value = {
loginName: data.loginName,
userName: data.userName,
password: '',
confirmPassword: '',
userType: data.userType,
email: data.email || '',
phone: data.phone || '',
sex: data.sex || '3',
userStatus: data.userStatus,
remark: data.remark || '',
roleIds: data.roleIds || [],
}
}
else {
// 如果获取详情失败,使用列表数据作为备用
formData.value = {
loginName: user.loginName,
userName: user.userName,
password: '',
confirmPassword: '',
userType: user.userType,
email: user.email || '',
phone: user.phone || '',
sex: user.sex || '3',
userStatus: user.userStatus,
remark: user.remark || '',
roleIds: [],
}
}
}
catch {
// 如果获取详情出错,使用列表数据作为备用
formData.value = {
loginName: user.loginName,
userName: user.userName,
password: '',
confirmPassword: '',
userType: user.userType,
email: user.email || '',
phone: user.phone || '',
sex: user.sex || '3',
userStatus: user.userStatus,
remark: user.remark || '',
roleIds: [],
}
}
userDialogRef.value?.coiOpen()
}
// 删除用户
async function handleDelete(userId: number) {
try {
const { isSuccess } = await deleteUser(userId)
if (isSuccess) {
coiMsgSuccess('删除成功')
await getUserList()
}
else {
coiMsgError('删除失败')
}
}
catch {
coiMsgError('删除失败')
}
}
// 批量删除用户
async function handleBatchDelete() {
if (selectedRows.value.length === 0) {
coiMsgWarning('请先选择要删除的用户')
return
}
try {
// 确认删除操作
await coiMsgBox(`确定要删除选中的 ${selectedRows.value.length} 个用户吗?`, '批量删除确认')
}
catch {
// 用户取消删除操作,直接返回,不显示任何提示
return
}
// 用户确认删除,执行删除操作
try {
const userIds = selectedRows.value.map(user => user.userId)
const { isSuccess } = await batchDeleteUsers(userIds)
if (isSuccess) {
coiMsgSuccess('批量删除成功')
selectedRows.value = []
await getUserList()
}
else {
coiMsgError('批量删除失败')
}
}
catch {
coiMsgError('批量删除失败')
}
}
// 切换用户状态
async function handleToggleStatus(user: UserVo) {
try {
const newStatus = user.userStatus === '0' ? '1' : '0'
const statusText = newStatus === '0' ? '启用' : '停用'
const { isSuccess } = await updateUserStatus(user.userId, newStatus)
if (isSuccess) {
user.userStatus = newStatus
coiMsgSuccess(`用户「${user.userName}」${statusText}成功`)
// 刷新用户列表以确保数据同步
await getUserList()
}
else {
coiMsgError(`用户${statusText}失败`)
}
}
catch {
coiMsgError('状态修改失败,请检查网络连接')
}
}
// 重置密码
function handleResetPassword(user: UserVo) {
currentResetUser.value = user
resetPwdForm.value = {
newPassword: '',
confirmPassword: '',
}
resetPwdDialogRef.value?.coiOpen()
}
// 执行重置密码
async function handleConfirmResetPassword() {
if (!resetPwdFormRef.value || !currentResetUser.value) {
return
}
try {
// 先进行表单验证
await resetPwdFormRef.value.validate()
}
catch {
// 表单验证失败,提示用户检查填写内容
coiMsgWarning('验证失败,请检查填写内容')
return
}
// 表单验证通过执行API调用
try {
const { isSuccess } = await resetUserPassword(currentResetUser.value.userId, resetPwdForm.value.newPassword)
if (isSuccess) {
coiMsgSuccess(`用户「${currentResetUser.value.userName}」密码重置成功`)
resetPwdDialogRef.value?.coiClose()
}
else {
coiMsgError('重置密码失败,请稍后重试')
}
}
catch {
coiMsgError('重置密码失败,请检查网络连接')
}
}
// 取消重置密码
function handleCancelResetPassword() {
resetPwdDialogRef.value?.coiClose()
resetPwdForm.value = {
newPassword: '',
confirmPassword: '',
}
}
// 查看头像
function handleViewAvatar(user: UserVo) {
currentAvatarUser.value = user
// 如果用户有头像URL使用头像URL否则生成默认头像
if (user.avatar) {
currentAvatar.value = user.avatar
}
else {
// 为没有头像的用户生成一个SVG默认头像
const firstLetter = user.loginName.charAt(0).toUpperCase()
const colors = [
'from-blue-400 to-purple-500',
'from-green-400 to-blue-500',
'from-pink-400 to-red-500',
'from-yellow-400 to-orange-500',
'from-indigo-400 to-purple-500',
]
const colorIndex = firstLetter.charCodeAt(0) % colors.length
const _gradientClass = colors[colorIndex]
// 创建SVG默认头像
const svg = `
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8B5CF6;stop-opacity:1" />
</linearGradient>
</defs>
<circle cx="100" cy="100" r="100" fill="url(#grad)" />
<text x="100" y="120" font-family="Arial, sans-serif" font-size="80" font-weight="bold"
text-anchor="middle" fill="white">${firstLetter}</text>
</svg>
`
const blob = new Blob([svg], { type: 'image/svg+xml' })
const blobUrl = URL.createObjectURL(blob)
currentAvatar.value = blobUrl
createdBlobUrls.value.push(blobUrl)
}
avatarDialogRef.value?.coiOpen()
}
// 关闭头像查看
function handleCloseAvatar() {
avatarDialogRef.value?.coiClose()
// 如果是生成的默认头像释放URL
if (currentAvatar.value.startsWith('blob:')) {
URL.revokeObjectURL(currentAvatar.value)
// 从追踪数组中移除
const index = createdBlobUrls.value.indexOf(currentAvatar.value)
if (index > -1) {
createdBlobUrls.value.splice(index, 1)
}
}
currentAvatar.value = ''
currentAvatarUser.value = null
}
// 头像加载错误处理
function handleAvatarError(event: Event) {
const img = event.target as HTMLImageElement
if (currentAvatarUser.value) {
// 重新生成默认头像
const firstLetter = currentAvatarUser.value.loginName.charAt(0).toUpperCase()
const svg = `
<svg width="320" height="320" viewBox="0 0 320 320" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="errorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#EF4444;stop-opacity:1" />
<stop offset="100%" style="stop-color:#F97316;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="320" height="320" rx="12" fill="url(#errorGrad)" />
<text x="160" y="190" font-family="Arial, sans-serif" font-size="120" font-weight="bold"
text-anchor="middle" fill="white">${firstLetter}</text>
</svg>
`
const blob = new Blob([svg], { type: 'image/svg+xml' })
const blobUrl = URL.createObjectURL(blob)
img.src = blobUrl
createdBlobUrls.value.push(blobUrl)
}
}
// 导出功能
const showExportDropdown = ref(false)
// 下载文件的通用函数
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
// 生成文件名
function generateFileName(prefix: string) {
const now = new Date()
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '')
const timeStr = now.toTimeString().slice(0, 8).replace(/:/g, '')
return `${prefix}_${dateStr}_${timeStr}.xlsx`
}
// 导出当前查询条件下的数据
async function handleExportCurrent() {
try {
showExportDropdown.value = false
// 构建查询参数
const { timeRange, ...otherParams } = searchForm.value
const params: any = { ...otherParams }
// 处理时间范围
if (timeRange && timeRange.length === 2) {
params.beginTime = new Date(timeRange[0]).toISOString().slice(0, 10)
params.endTime = new Date(timeRange[1]).toISOString().slice(0, 10)
}
const response = await exportExcelData(params)
const filename = generateFileName('用户数据_筛选')
downloadBlob(response, filename)
coiMsgSuccess('导出成功')
}
catch {
coiMsgError('导出失败请重试')
}
}
// 导出全部数据
async function handleExportAll() {
try {
showExportDropdown.value = false
const response = await exportExcelData()
const filename = generateFileName('用户数据_全部')
downloadBlob(response, filename)
coiMsgSuccess('导出成功')
}
catch {
coiMsgError('导出失败请重试')
}
}
// 下载导入模板
async function handleDownloadTemplate() {
try {
showExportDropdown.value = false
const response = await downloadExcelTemplate()
const filename = '用户导入模板.xlsx'
downloadBlob(response, filename)
coiMsgSuccess('模板下载成功')
}
catch {
coiMsgError('模板下载失败请重试')
}
}
// 导出菜单处理
function handleExportMenuSelect(key: string) {
switch (key) {
case 'current':
handleExportCurrent()
break
case 'all':
handleExportAll()
break
case 'template':
handleDownloadTemplate()
break
}
}
// 导入功能
function handleImport() {
// 重置导入状态
selectedFile.value = null
updateSupport.value = false
uploadProgress.value = 0
importDialogRef.value?.coiOpen()
}
// 文件选择处理
function handleFileSelect(file: File) {
// 添加调试日志
// 验证文件类型
const allowedTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel', // .xls
]
if (!allowedTypes.includes(file.type)) {
coiMsgError('请选择Excel文件.xlsx或.xls格式')
return false
}
// 验证文件大小限制为2MB
const fileSizeMB = file.size / 1024 / 1024
if (fileSizeMB > 2) {
coiMsgError(`Excel文件大小超出限制当前文件${fileSizeMB.toFixed(2)}MB最大允许2MB`)
return false
}
selectedFile.value = file
return true
}
// 执行导入
async function handleConfirmImport() {
if (!selectedFile.value) {
coiMsgError('请先选择要导入的文件')
return
}
try {
importLoading.value = true
uploadProgress.value = 0
// 清理之前的定时器
if (progressInterval.value) {
clearInterval(progressInterval.value)
progressInterval.value = null
}
// 模拟上传进度
progressInterval.value = setInterval(() => {
if (uploadProgress.value < 90) {
uploadProgress.value += 10
}
}, 200)
const response = await importUserData(selectedFile.value, updateSupport.value)
if (progressInterval.value) {
clearInterval(progressInterval.value)
progressInterval.value = null
}
uploadProgress.value = 100
if (response.isSuccess && response.data) {
const result = response.data
coiMsgSuccess(
`导入完成!总数:${result.total},成功:${result.success},失败:${result.failed}`,
)
// 关闭模态框并刷新列表
importDialogRef.value?.coiClose()
await getUserList()
}
else {
coiMsgError(response.message || '导入失败,请重试')
}
}
catch (error: any) {
// 解析后端返回的错误信息
let errorMessage = '导入失败,请检查文件格式或联系管理员'
// 检查是否是服务返回的结构化错误
if (error?.isSuccess === false) {
// alova 返回的结构化错误
if (error.message) {
errorMessage = error.message
}
else if (error.msg) {
errorMessage = error.msg
}
}
else if (error?.response?.data?.msg) {
// 原始HTTP响应错误
errorMessage = error.response.data.msg
}
else if (error?.message) {
// 网络错误或其他错误
if (error.message.includes('413')) {
errorMessage = 'Excel文件大小超出限制请选择较小的文件'
}
else if (error.message.includes('400')) {
errorMessage = 'Excel文件格式不支持或文件内容有误'
}
else {
errorMessage = error.message
}
}
coiMsgError(errorMessage)
}
finally {
// 清理定时器
if (progressInterval.value) {
clearInterval(progressInterval.value)
progressInterval.value = null
}
importLoading.value = false
uploadProgress.value = 0
}
}
// 取消导入
function handleCancelImport() {
importDialogRef.value?.coiClose()
selectedFile.value = null
updateSupport.value = false
uploadProgress.value = 0
}
// 分配角色
async function handleAssignRole(user: UserVo) {
if (!user?.userId) {
coiMsgError('用户信息无效')
return
}
try {
roleLoading.value = true
currentAssignUser.value = user
roleModalTitle.value = `分配角色 - ${user.userName}`
// 重置状态
availableRoles.value = []
selectedRoleIds.value = []
// 获取可用角色列表
const response = await fetchNormalRoleForUser(user.userId)
if (response.isSuccess && response.data) {
const allRoles = response.data.data1 || []
const userRoleIds = response.data.data2 || []
if (Array.isArray(allRoles) && allRoles.length > 0) {
// 直接使用后端返回的 label 和 value 字段
availableRoles.value = allRoles.map(item => ({
label: item.label,
value: item.value,
}))
// 预选用户当前角色使用后端返回的用户角色ID
selectedRoleIds.value = userRoleIds
roleDialogRef.value?.coiOpen()
}
else {
coiMsgInfo('当前系统没有可分配的角色')
}
}
else {
coiMsgError(response.msg || '获取角色列表失败')
}
}
catch {
coiMsgError('获取角色列表失败,请检查网络连接')
}
finally {
roleLoading.value = false
}
}
// 确认分配角色
async function handleConfirmAssignRole() {
if (!currentAssignUser.value) {
coiMsgError('用户信息缺失')
return
}
try {
roleLoading.value = true
// 构建角色ID字符串
// 如果没有选择角色,传递"-1"表示清空所有角色
const roleIdsStr = selectedRoleIds.value.length > 0
? selectedRoleIds.value.join(',')
: '-1'
const response = await assignUserRole(currentAssignUser.value.userId, roleIdsStr)
if (response.isSuccess) {
coiMsgSuccess('角色分配成功')
roleDialogRef.value?.coiClose()
// 更新当前用户的角色信息
if (currentAssignUser.value) {
currentAssignUser.value.roleIds = selectedRoleIds.value
}
// 刷新用户列表
await getUserList()
}
else {
coiMsgError(response.msg || '角色分配失败')
}
}
catch {
coiMsgError('角色分配失败,请检查网络连接')
}
finally {
roleLoading.value = false
}
}
// 取消分配角色
function handleCancelAssignRole() {
roleDialogRef.value?.coiClose()
currentAssignUser.value = null
selectedRoleIds.value = []
availableRoles.value = []
roleModalTitle.value = '分配角色'
roleLoading.value = false
}
// 提交表单
async function handleSubmit() {
if (!formRef.value)
return
try {
// 先进行表单验证
await formRef.value.validate()
}
catch {
// 表单验证失败,提示用户检查填写内容
coiMsgWarning('验证失败,请检查填写内容')
return
}
// 表单验证通过执行API调用
try {
const submitData = {
...formData.value,
}
delete submitData.confirmPassword
if (isEdit.value && !submitData.password) {
delete submitData.password
}
if (isEdit.value && currentUser.value) {
submitData.userId = currentUser.value.userId
}
const { isSuccess } = isEdit.value ? await updateUser(submitData) : await addUser(submitData)
if (isSuccess) {
coiMsgSuccess(isEdit.value ? '用户信息更新成功' : '用户创建成功')
userDialogRef.value?.coiClose()
await getUserList()
}
else {
coiMsgError(isEdit.value ? '更新失败,请稍后重试' : '创建失败,请稍后重试')
}
}
catch {
coiMsgError(isEdit.value ? '更新失败,请检查网络连接' : '创建失败,请检查网络连接')
}
}
// 取消操作
function handleCancel() {
userDialogRef.value?.coiClose()
}
// 判断是否有搜索条件
function hasSearchConditions() {
return Object.values(searchForm.value).some((value) => {
if (value === null || value === undefined || value === '') {
return false
}
if (Array.isArray(value) && value.length === 0) {
return false
}
return true
})
}
// 获取空状态类型
function getEmptyType() {
if (hasSearchConditions()) {
return 'search'
}
return 'default'
}
// 获取空状态标题
function getEmptyTitle() {
if (hasSearchConditions()) {
return '搜索无结果'
}
return '暂无用户数据'
}
// 获取空状态描述
function getEmptyDescription() {
if (hasSearchConditions()) {
return '未找到符合搜索条件的用户,请尝试调整搜索条件或重置筛选'
}
return '当前还没有用户数据,点击"新增"按钮创建第一个用户'
}
// 获取空状态操作文字
function getEmptyActionText() {
if (hasSearchConditions()) {
return '重置筛选'
}
return '新增用户'
}
// 处理空状态操作
function handleEmptyAction() {
if (hasSearchConditions()) {
handleReset()
}
else {
handleAdd()
}
}
// 自定义角色标签渲染
function renderRoleTag({ option, handleClose }: { option: any, handleClose: () => void }) {
return h(NTag, {
type: 'primary',
size: 'small',
closable: true,
onClose: handleClose,
style: {
marginRight: '6px',
marginBottom: '2px',
borderRadius: '6px',
fontSize: '12px',
padding: '4px 8px',
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
color: '#ffffff',
boxShadow: '0 2px 4px rgba(99, 102, 241, 0.2)',
},
}, {
default: () => option.label,
})
}
// 组件挂载时获取数据
onMounted(() => {
getUserList()
getRoleList()
})
// 组件卸载前清理资源
onBeforeUnmount(() => {
// 清理定时器
if (progressInterval.value) {
clearInterval(progressInterval.value)
progressInterval.value = null
}
// 清理所有创建的Blob URL
createdBlobUrls.value.forEach((url) => {
URL.revokeObjectURL(url)
})
createdBlobUrls.value = []
// 清理当前头像URL
if (currentAvatar.value.startsWith('blob:')) {
URL.revokeObjectURL(currentAvatar.value)
}
})
</script>
<style scoped>
.user-management {
height: 100vh;
overflow: hidden;
}
.custom-table :deep(.n-data-table-td) {
padding: 7px 12px;
font-size: 14px;
}
.custom-table :deep(.n-data-table-th) {
padding: 9px 12px;
background-color: #fafafa;
font-weight: normal;
font-size: 14px;
color: #262626;
}
.custom-table :deep(.n-data-table-tr:hover .n-data-table-td) {
background-color: #f8faff;
}
.search-form :deep(.n-form-item-label) {
font-weight: 500;
font-size: 14px;
color: #262626;
}
/* 操作按钮样式优化 */
.custom-table :deep(.n-button--small-type) {
font-size: 12px;
padding: 4px 8px;
}
/* 标签尺寸优化 */
.custom-table :deep(.n-tag--small-type) {
font-size: 11px;
padding: 2px 6px;
}
/* 自定义滚动条样式 */
.table-wrapper {
min-height: 0;
}
.table-wrapper::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.table-wrapper::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.table-wrapper::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.table-wrapper::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.table-wrapper::-webkit-scrollbar-corner {
background: #f1f1f1;
}
/* 表格滚动条样式 */
.custom-table :deep(.n-data-table-base-table) {
overflow: auto;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-track {
background: #f8f9fa;
border-radius: 3px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-thumb {
background: #dee2e6;
border-radius: 3px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-thumb:hover {
background: #adb5bd;
}
.user-modal :deep(.n-card-header) {
padding: 24px 24px 0;
font-size: 18px;
font-weight: 600;
}
.user-modal :deep(.n-card__content) {
padding: 24px;
}
.role-assign-modal :deep(.n-card-header) {
padding: 20px 24px 0;
font-size: 18px;
font-weight: 600;
}
.role-assign-modal :deep(.n-card__content) {
padding: 20px 24px;
}
.role-transfer :deep(.n-transfer-list) {
height: 300px;
}
.role-transfer :deep(.n-transfer-list-header) {
font-weight: 600;
background-color: #f5f5f5;
}
.avatar-modal :deep(.n-card-header) {
padding: 20px 24px 0;
border-bottom: 1px solid #f0f0f0;
}
.avatar-modal :deep(.n-card__content) {
padding: 24px;
}
.avatar-modal :deep(.n-card__footer) {
padding: 0 24px 20px;
border-top: 1px solid #f0f0f0;
}
.import-modal :deep(.n-card-header) {
padding: 16px 20px 0;
border-bottom: 1px solid #f0f0f0;
}
.import-modal :deep(.n-card__content) {
padding: 20px;
}
.import-modal :deep(.n-card__footer) {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
.import-modal :deep(.n-upload-dragger) {
border: 2px dashed #d9d9d9;
border-radius: 6px;
background: #fafafa;
padding: 40px 20px;
transition: border-color 0.3s;
}
.import-modal :deep(.n-upload-dragger:hover) {
border-color: #40a9ff;
}
/* CoiEmpty按钮样式 */
.coi-empty__action-btn {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border: none;
}
.coi-empty__action-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.6s ease;
}
.coi-empty__action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.coi-empty__action-btn:hover::before {
left: 100%;
}
/* 邮箱字段样式 - 确保tooltip文字清晰 */
.email-cell-text {
color: #6b7280; /* 对应 text-gray-500在表格中显示为灰色 */
}
/* 确保邮箱字段的tooltip内文字清晰可见覆盖继承的灰色 */
:deep(.n-tooltip) .email-cell-text,
:deep(.n-tooltip__content) .email-cell-text,
:deep(.n-ellipsis__tooltip) .email-cell-text {
color: #ffffff !important; /* 强制tooltip内文字为白色 */
}
/* 针对ellipsis tooltip的特殊处理 */
:deep(.n-ellipsis__tooltip) {
color: #ffffff !important;
}
:deep(.n-ellipsis__tooltip *) {
color: #ffffff !important;
}
/* 角色选择器样式优化 */
.role-select :deep(.n-base-selection) {
border-radius: 8px;
border: 1px solid #e5e7eb;
transition: all 0.3s ease;
}
.role-select :deep(.n-base-selection:hover) {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.role-select :deep(.n-base-selection.n-base-selection--focus) {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.role-select :deep(.n-base-selection-tags) {
padding: 4px;
min-height: 40px;
}
/* 角色标签样式 */
.role-select :deep(.n-tag) {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
border: none !important;
color: #ffffff !important;
border-radius: 6px !important;
font-size: 12px !important;
font-weight: 500 !important;
padding: 4px 8px !important;
margin: 2px 4px 2px 0 !important;
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.2) !important;
transition: all 0.2s ease !important;
}
.role-select :deep(.n-tag:hover) {
transform: translateY(-1px) !important;
box-shadow: 0 4px 8px rgba(99, 102, 241, 0.3) !important;
}
.role-select :deep(.n-tag .n-base-close) {
color: rgba(255, 255, 255, 0.8) !important;
transition: color 0.2s ease !important;
}
.role-select :deep(.n-tag .n-base-close:hover) {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.2) !important;
border-radius: 50% !important;
}
</style>