coder-common-thin-frontend/src/views/system/user/index.vue
Leo 92ab0c3159 refactor(user): 移除不必要的组件状态追踪代码
- 删除 isComponentMounted 状态变量及相关检查逻辑
- 简化异步函数中的组件挂载状态验证
- 保留必要的资源清理逻辑(定时器和Blob URL)
- 优化代码结构,提高可维护性

Vue 3 的响应式系统已经能够自动处理组件卸载时的状态更新,
无需手动追踪组件生命周期状态。
2025-07-06 02:52:45 +08:00

1913 lines
55 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.

<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 {
addUser,
batchDeleteUsers,
deleteUser,
downloadExcelTemplate,
exportExcelData,
fetchUserPage,
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'
// 响应式数据
const loading = ref(false)
const tableData = ref<UserVo[]>([])
const showModal = ref(false)
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 showRoleModal = ref(false)
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 showResetPwdModal = ref(false)
const resetPwdFormRef = ref<FormInst | null>(null)
const currentResetUser = ref<UserVo | null>(null)
const resetPwdForm = ref({
newPassword: '',
confirmPassword: '',
})
// 头像查看相关
const showAvatarModal = ref(false)
const currentAvatar = ref('')
const currentAvatarUser = ref<UserVo | null>(null)
// 导入相关
const showImportModal = ref(false)
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: 'font-medium text-gray-900' }, 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: 'text-gray-600' }, 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', round: true }, { 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: 300,
align: 'center',
fixed: 'right',
render: (row) => {
return h('div', { class: 'flex items-center justify-center gap-2' }, [
h(NButton, {
type: 'primary',
size: 'small',
onClick: () => handleEdit(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, { default: () => h('icon-park-outline:edit') }),
default: () => '编辑',
}),
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('icon-park-outline:delete') }),
default: () => '删除',
}),
}),
h(NButton, {
type: 'warning',
size: 'small',
onClick: () => handleResetPassword(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, { default: () => h('icon-park-outline:refresh') }),
default: () => '重置密码',
}),
h(NButton, {
type: 'info',
size: 'small',
onClick: () => handleAssignRole(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, { default: () => h('icon-park-outline:setting') }),
default: () => '分配角色',
}),
])
},
},
]
// 获取用户列表
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 {
console.warn('获取用户列表失败,可能是权限或网络问题')
coiMsgError('获取用户列表失败,请检查网络连接或联系管理员')
tableData.value = []
pagination.value.itemCount = 0
}
}
catch (error) {
console.error('获取用户列表失败:', error)
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 (error) {
console.warn('获取角色列表失败,将不显示角色选择:', error)
}
}
// 分页变化处理
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: [],
}
showModal.value = true
}
// 编辑用户
function handleEdit(user: UserVo) {
modalTitle.value = '编辑用户'
isEdit.value = true
currentUser.value = user
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: user.roleIds || [],
}
showModal.value = true
}
// 删除用户
async function handleDelete(userId: number) {
try {
const { isSuccess } = await deleteUser(userId)
if (isSuccess) {
coiMsgSuccess('删除成功')
await getUserList()
}
else {
coiMsgError('删除失败')
}
}
catch (error) {
console.error('删除失败:', error)
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 (error) {
console.error('批量删除失败:', error)
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 (error) {
console.error('状态修改失败:', error)
coiMsgError('状态修改失败,请检查网络连接')
}
}
// 重置密码
function handleResetPassword(user: UserVo) {
currentResetUser.value = user
resetPwdForm.value = {
newPassword: '',
confirmPassword: '',
}
showResetPwdModal.value = true
}
// 执行重置密码
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}」密码重置成功`)
showResetPwdModal.value = false
}
else {
coiMsgError('重置密码失败,请稍后重试')
}
}
catch (error) {
console.error('重置密码API调用失败:', error)
coiMsgError('重置密码失败,请检查网络连接')
}
}
// 取消重置密码
function handleCancelResetPassword() {
showResetPwdModal.value = false
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)
}
showAvatarModal.value = true
}
// 关闭头像查看
function handleCloseAvatar() {
showAvatarModal.value = false
// 如果是生成的默认头像释放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 (error) {
console.error('导出失败:', error)
coiMsgError('导出失败,请重试')
}
}
// 导出全部数据
async function handleExportAll() {
try {
showExportDropdown.value = false
const response = await exportExcelData()
const filename = generateFileName('用户数据_全部')
downloadBlob(response, filename)
coiMsgSuccess('导出成功')
}
catch (error) {
console.error('导出失败:', error)
coiMsgError('导出失败,请重试')
}
}
// 下载导入模板
async function handleDownloadTemplate() {
try {
showExportDropdown.value = false
const response = await downloadExcelTemplate()
const filename = '用户导入模板.xlsx'
downloadBlob(response, filename)
coiMsgSuccess('模板下载成功')
}
catch (error) {
console.error('模板下载失败:', error)
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
showImportModal.value = true
}
// 文件选择处理
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
}
// 验证文件大小限制为10MB
if (file.size > 10 * 1024 * 1024) {
coiMsgError('文件大小不能超过10MB')
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}`,
)
// 关闭模态框并刷新列表
showImportModal.value = false
await getUserList()
}
else {
coiMsgError(response.message || '导入失败,请重试')
}
}
catch (error) {
console.error('导入失败:', error)
coiMsgError('导入失败,请检查文件格式或联系管理员')
}
finally {
// 清理定时器
if (progressInterval.value) {
clearInterval(progressInterval.value)
progressInterval.value = null
}
importLoading.value = false
uploadProgress.value = 0
}
}
// 取消导入
function handleCancelImport() {
showImportModal.value = false
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
showRoleModal.value = true
}
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('角色分配成功')
showRoleModal.value = false
// 更新当前用户的角色信息
if (currentAssignUser.value) {
currentAssignUser.value.roleIds = selectedRoleIds.value
}
// 刷新用户列表
await getUserList()
}
else {
coiMsgError(response.msg || '角色分配失败')
}
}
catch {
coiMsgError('角色分配失败,请检查网络连接')
}
finally {
roleLoading.value = false
}
}
// 取消分配角色
function handleCancelAssignRole() {
showRoleModal.value = false
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 ? '用户信息更新成功' : '用户创建成功')
showModal.value = false
await getUserList()
}
else {
coiMsgError(isEdit.value ? '更新失败,请稍后重试' : '创建失败,请稍后重试')
}
}
catch (error) {
console.error('API调用失败:', error)
coiMsgError(isEdit.value ? '更新失败,请检查网络连接' : '创建失败,请检查网络连接')
}
}
// 取消操作
function handleCancel() {
showModal.value = false
}
// 组件挂载时获取数据
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>
<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="7" :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 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
type="error"
:disabled="selectedRows.length === 0"
class="px-6 flex items-center"
@click="handleBatchDelete"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:delete />
</NIcon>
</template>
删除
</NButton>
<NButton
type="info"
:disabled="selectedRows.length !== 1"
class="px-6 flex items-center"
@click="handleAssignRole(selectedRows[0])"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:setting />
</NIcon>
</template>
分配角色
</NButton>
<NDropdown
trigger="click"
:show="showExportDropdown"
:options="[
{
label: '导出当前查询数据',
key: 'current',
icon: () => h(NIcon, null, { default: () => h('icon-park-outline:download') }),
},
{
label: '导出全部数据',
key: 'all',
icon: () => h(NIcon, null, { default: () => h('icon-park-outline:download-one') }),
},
{
type: 'divider',
},
{
label: '下载导入模板',
key: 'template',
icon: () => h(NIcon, null, { default: () => h('icon-park-outline:file-code-one') }),
},
]"
@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 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
:columns="columns"
:data="tableData"
:loading="loading"
:row-key="(row: UserVo) => row.userId"
:bordered="false"
:single-line="false"
size="large"
class="custom-table"
@update:checked-row-keys="handleRowSelectionChange"
/>
</div>
<!-- 分页器 -->
<div class="flex items-center px-4 py-2 border-t border-gray-100">
<div class="text-sm text-gray-500 mr-4">
共 {{ pagination.itemCount }} 条
</div>
<n-pagination
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
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<!-- 用户表单模态框 -->
<n-modal
v-model:show="showModal"
:title="modalTitle"
preset="card"
:style="{ width: '800px' }"
:mask-closable="false"
class="user-modal"
>
<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="请选择角色"
: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>
<template #footer>
<div class="flex justify-end gap-3">
<NButton @click="handleCancel">
取消
</NButton>
<NButton type="primary" @click="handleSubmit">
确定
</NButton>
</div>
</template>
</n-modal>
<!-- 角色分配模态框 -->
<n-modal
v-model:show="showRoleModal"
:title="roleModalTitle"
preset="card"
:style="{ width: '600px' }"
:mask-closable="false"
class="role-assign-modal"
>
<div class="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 #footer>
<div class="flex justify-end gap-3">
<NButton
:disabled="roleLoading"
@click="handleCancelAssignRole"
>
取消
</NButton>
<NButton
type="primary"
:loading="roleLoading"
:disabled="availableRoles.length === 0"
@click="handleConfirmAssignRole"
>
确定
</NButton>
</div>
</template>
</n-modal>
<!-- 重置密码模态框 -->
<n-modal
v-model:show="showResetPwdModal"
title="重置用户密码"
preset="card"
:style="{ width: '500px' }"
:mask-closable="false"
class="reset-pwd-modal"
>
<div class="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 #footer>
<div class="flex justify-end gap-3">
<NButton @click="handleCancelResetPassword">
取消
</NButton>
<NButton type="primary" @click="handleConfirmResetPassword">
确认重置
</NButton>
</div>
</template>
</n-modal>
<!-- 头像查看模态框 -->
<n-modal
v-model:show="showAvatarModal"
preset="card"
:style="{ width: '600px' }"
:mask-closable="true"
class="avatar-modal"
@close="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>
<div class="flex flex-col items-center space-y-4">
<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 #footer>
<div class="flex justify-end">
<NButton @click="handleCloseAvatar">
关闭
</NButton>
</div>
</template>
</n-modal>
<!-- 导入用户模态框 -->
<n-modal
v-model:show="showImportModal"
title="导入用户数据"
preset="card"
:style="{ width: '600px' }"
:mask-closable="false"
class="import-modal"
>
<div class="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>
<template #footer>
<div class="flex justify-between">
<NButton
:disabled="importLoading"
@click="handleDownloadTemplate"
>
<template #icon>
<NIcon><icon-park-outline:download /></NIcon>
</template>
下载模板
</NButton>
<div class="flex gap-3">
<NButton :disabled="importLoading" @click="handleCancelImport">
取消
</NButton>
<NButton
type="primary"
:loading="importLoading"
:disabled="!selectedFile"
@click="handleConfirmImport"
>
开始导入
</NButton>
</div>
</div>
</template>
</n-modal>
</div>
</template>
<style scoped>
.user-management {
height: 100vh;
overflow: hidden;
}
.custom-table :deep(.n-data-table-td) {
padding: 8px 12px;
}
.custom-table :deep(.n-data-table-th) {
padding: 10px 12px;
background-color: #fafafa;
font-weight: 600;
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;
color: #262626;
}
.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;
}
</style>