2257 lines
64 KiB
Vue
2257 lines
64 KiB
Vue
<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>
|