Files
moc/src/views/systemManage/user/index.vue
T

871 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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 { ref, computed, h, onMounted } from 'vue';
import { useAppStore } from '@/store/modules/app';
import { pinyin } from 'pinyin-pro';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { NButton, NPopconfirm, NTag, NDropdown, useDialog } from 'naive-ui';
import type { DataTableColumns } from 'naive-ui';
import { roleListApi } from '@/service/api/role';
import {
orgListApi,
addOrgApi,
editOrgApi,
deleteOrgApi,
userListApi,
addUserApi,
editUserApi,
deleteUserApi,
resetDataApi,
downloadTemplateApi,
importUserApi,
resetPasswordApi
} from '@/service/api/user';
const appStore = useAppStore();
const themeStore = useThemeStore();
const gap = computed(() => (appStore.isMobile ? 0 : 16));
const dialog = useDialog()
const roleList = ref<{ label: string, value: number }[]>([]);
const restLoading = ref<boolean>(false);
const treeLoading = ref<boolean>(false);
const loading = ref<boolean>(false);
const btnLoading = ref<boolean>(false);
const currentType = ref<string>('add');
const currentOrg = ref<number>(1);
const formRef = ref<any>(null);
const keyword = ref<string>('');
// 当前用户
const currentUser = ref<{ id: number, real_name: string, dept_ids: number[], mobile?: string, email?: string, username: string, role_id: number | null, org_ids: number[], enabled: boolean, gender: number }>({
id: 0,
real_name: '',
dept_ids: [],
mobile: '',
email: '',
username: '',
role_id: null,
org_ids: [],
enabled: true,
gender: 1,
});
// 校验规则
const rules = {
real_name: { required: true, message: '姓名不能为空', trigger: ['blur'] },
dept_ids: {
type: 'array',
required: true,
trigger: ['blur', 'change'],
message: '请选择所属部门'
},
username: { required: true, message: '账号不能为空', trigger: ['blur'] },
role_id: { type: 'number', required: true, message: '请选择账号角色', trigger: ['blur', 'change'] },
org_ids: {
type: 'array',
required: true,
trigger: ['blur', 'change'],
message: '请选择数据权限'
},
}
const userModal = ref<boolean>(false);
const pagination = ref({
page: 1,
pageSize: 3,
itemCount: 0,
})
const list = ref<{ id: number, name: string, dept: string[], mobile: string, email: string, account: string, role: string | null, dataPermission: string[], status: number, gender: number }[]>([])
const formNodeRef = ref<any>(null);
const treeModal = ref<boolean>(false);
const currentNode = ref<{ id: number, label: string, children: any[], is_bottom: number, org_code: string, parent_id: number, tier: number, sort: number }>({
id: 0,
label: '',
children: [],
is_bottom: 0,
org_code: '',
parent_id: 0,
tier: 0,
sort: 0,
})
const formNode = ref<{ label: string, is_bottom: number, org_code: string, parent_id: number, sort: number }>({
label: '',
is_bottom: 0,
org_code: '',
parent_id: 0,
sort: 0,
})
const currentNodeType = ref<string>('edit')
// 树节点规则
const treeRules = ref({
label: [{ required: true, message: '请输入层级名称', trigger: ['blur'] }],
})
// 左侧组织树
const orgTree = ref<any[]>([]);
const expandedKeys = ref<number[]>([1])
const selectedKeys = ref<number[]>([1])
// 右键菜单相关
const menuVisible = ref(false)
const menuX = ref(0)
const menuY = ref(0)
// 菜单选项
const menuOptions = [
{ label: '编辑', key: 'edit' },
{ label: '添加子级', key: 'add' },
{ label: '删除', key: 'delete' },
]
// 展开节点
const handleExpandKeys = (keys: number[]) => {
expandedKeys.value = keys
}
// 选中节点
const handleSelectKeys = (keys: number[]) => {
selectedKeys.value = keys
currentOrg.value = keys[0];
pagination.value.page = 1;
getList()
}
// 处理右键点击树节点
const treeProps = ({ option }: { option: any }) => {
return {
onContextmenu(e: MouseEvent) {
e.preventDefault()
currentNode.value = option
menuX.value = e.clientX
menuY.value = e.clientY
menuVisible.value = true
},
}
}
// 关闭菜单
const closeMenu = () => {
menuVisible.value = false
}
// 点击菜单项
const handleMenuSelect = (key: string) => {
currentNodeType.value = key
switch (key) {
case 'edit':
formNode.value = currentNode.value
treeModal.value = true
break
case 'add':
formNode.value = {
label: '',
is_bottom: 0,
org_code: '',
parent_id: currentNode.value.id,
sort: 0,
}
treeModal.value = true
break
case 'delete':
const d = dialog.info({
title: '删除',
content: `确定删除层级「${currentNode.value.label}」吗? 该操作不可恢复`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
d.loading = true
return new Promise(async (resolve) => {
const {error} = await deleteOrgApi(currentNode.value.id);
if(!error){
window.$message?.success('删除成功')
getOrgList()
}
d.loading = false
resolve(true)
})
},
onNegativeClick: () => {
d.loading = false
}
})
break
}
closeMenu()
}
// 自定义渲染展开图标
const renderSwitcherIcon = (node:any) => {
// 如果节点没有 children 或 children 为空数组,则不显示图标
if (!node.option.children || node.option.children.length === 0) {
return null; // 返回 null 表示不渲染任何图标
}
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
return h(Icon,{
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
class: 'size-12px',
});
}
// 自定义渲染后缀图标
const renderSuffix = ({ option }: { option: any }) => {
return h(
NButton,
{
text: true,
type: 'primary',
onClick: (e: MouseEvent) => {
e.stopPropagation();
currentNode.value = option
menuX.value = e.clientX
menuY.value = e.clientY
menuVisible.value = true
},
},
{
icon: () =>
h(Icon, { icon: 'lucide:more-horizontal', class: 'size-14px' }),
}
)
};
// 处理数据权限选择变化
const handleUpdateChecked = (keys: number[]) => {
currentUser.value.org_ids = keys
}
// 打开添加用户弹框
const addUser = () => {
currentType.value = 'add';
currentUser.value = {
id: 0,
real_name: '',
dept_ids: [],
mobile: '',
email: '',
username: '',
role_id: null,
org_ids: [],
enabled: true,
gender: 1,
};
userModal.value = true;
}
// 重置密码
const resetPassword = async (row: any) => {
const {error} = await resetPasswordApi(row.id);
if(!error){
window.$message?.success('重置密码成功')
}
}
// 编辑用户
const handleEdit = (row: any) => {
currentType.value = 'edit';
currentUser.value = {
id: row.id,
real_name: row.real_name,
username: row.username,
dept_ids: row.user_departments,
mobile: row.mobile,
email: row.email,
role_id: row.role_id,
org_ids: row.org_ids,
enabled: row.status,
gender: row.gender,
};
userModal.value = true;
}
// 删除用户
const handleDelete = async (row: any) => {
loading.value = true;
const {error} = await deleteUserApi({id: row.id, type: 3});
if(!error){
window.$message?.success('删除成功')
getList()
}
loading.value = false;
}
const columns = ref<DataTableColumns>([
{
title: '序号',
key: 'index',
width: 5,
align: 'center',
render: (_row: any, index: number) => {
return h('span', {}, { default: () => (pagination.value.page - 1) * pagination.value.pageSize + index + 1 })
}
},
{
title: '姓名',
key: 'real_name',
width: 8,
align: 'center',
},
{
title: '部门',
key: 'user_departments',
width: 10,
align: 'center',
render: (_row: any) => {
return h('span', {}, { default: () => getOrgLabel(_row.user_departments) })
}
},
{
title: '手机号码',
key: 'mobile',
width: 12,
align: 'center',
},
{
title: '账号',
key: 'username',
width: 10,
align: 'center',
},
{
title: '账号角色',
key: 'role_name',
width: 10,
align: 'center',
},
{
title: '状态',
key: 'status',
width: 8,
align: 'center',
render: (row: any) => {
return h(NTag, {
size: 'small',
type: `${row.status?'success':'error'}`
}, { default: () => `${row.status?'启用':'禁用'}` })
}
},
{
title: '操作',
key: 'action',
width: 14,
align: 'center',
render: (row: any) => {
return h('div', { class: 'flex items-center justify-center gap-3' }, {
default: () => [
h(NButton, {
text: true,
size: 'tiny',
type: 'primary',
onClick: () => handleEdit(row)
}, {
// 渲染按钮文本
default: () => '编辑'
}),
h(NPopconfirm, {
// 组件 Props
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
handleDelete(row)
},
// 可选:自定义确认/取消按钮的样式
positiveButtonProps: { size: 'tiny', type: 'primary' },
negativeButtonProps: { size: 'tiny' }
}, {
// 插槽 (Slots)
trigger: () => h(NButton,
{
text: true,
size: 'tiny',
type: 'error',
},
{
default: () => '删除' // 按钮文字
}
),
default: () => '确定要删除吗?' // 弹出框的提示内容
}),
h(NPopconfirm, {
// 组件 Props
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
resetPassword(row)
},
// 可选:自定义确认/取消按钮的样式
positiveButtonProps: { size: 'tiny', type: 'primary' },
negativeButtonProps: { size: 'tiny' }
}, {
// 插槽 (Slots)
trigger: () => h(NButton,
{
text: true,
size: 'tiny',
type: 'primary',
},
{
default: () => '重置密码' // 按钮文字
}
),
default: () => `确定重置该用户密码?` // 弹出框的提示内容
}),
]
})
}
},
])
// 导入用户
const importUser = () => {
const dom = document.createElement('input');
dom.type = 'file';
dom.style.display = 'none';
dom.click();
dom.onchange = async (e: any) => {
const formData = new FormData();
formData.append('file', e.target.files[0]);
const {error} = await importUserApi(formData);
if(!error){
window.$message?.success('导入成功')
pagination.value.page = 1;
getList()
}
};
}
// 下载模板
const downloadTemplate = async () => {
const {data,error} = await downloadTemplateApi();
if(!error){
// 确保 res 为 Blob 类型(若 API 返回 ArrayBuffer 或原始数据,需适配)
let blob;
if (data instanceof Blob) {
blob = data;
} else {
// 假设返回的是二进制数据(如 ArrayBuffer),指定 MIME 类型
blob = new Blob([data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = '用户导入模板.xlsx'; // 推荐使用 download 属性
document.body.appendChild(link);
link.click();
// 清理:移除链接并释放 URL 对象
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
};
// 重置数据权限
const refreshData = async () => {
restLoading.value = true;
const {error} = await resetDataApi();
if(!error){
window.$message?.success('重置成功')
}
restLoading.value = false;
}
// 保存用户
const saveUser = (e: MouseEvent) => {
e.preventDefault()
formRef.value?.validate(async (errors: any) => {
if (!errors) {
btnLoading.value = true;
// 新增
if(currentType.value === 'add'){
const { id, ...rest } = currentUser.value;
const {error} = await addUserApi(rest);
if(!error){
window.$message?.success('添加成功')
userModal.value = false;
getList();
}
}
// 编辑
if(currentType.value === 'edit'){
const {error} = await editUserApi(currentUser.value.id, currentUser.value);
if(!error){
window.$message?.success('编辑成功')
userModal.value = false;
getList();
}
}
btnLoading.value = false;
}
})
}
// 保存节点
const saveNode = (e: MouseEvent) => {
e.preventDefault()
formNodeRef.value?.validate(async (errors: any) => {
if (!errors) {
btnLoading.value = true;
// 新增
if(currentNodeType.value === 'add'){
const {error} = await addOrgApi(formNode.value);
if(!error){
treeModal.value = false;
window.$message?.success('添加成功')
getOrgList();
}
}
// 编辑
if(currentNodeType.value === 'edit'){
const {error} = await editOrgApi(currentNode.value.id, formNode.value);
if(!error){
treeModal.value = false;
window.$message?.success('编辑成功')
getOrgList();
}
}
btnLoading.value = false;
}
})
}
// 转换为拼音
const convertToPinyin = async () => {
formNode.value.org_code = pinyin(formNode.value.label, {
toneType: 'none',
type: 'array',
pattern: 'first',
}).join('').toUpperCase();
};
// 获取列表
const getList = async () => {
loading.value = true;
const {data,error} = await userListApi({
org_id: currentOrg.value,
keyword: keyword.value,
page: pagination.value.page,
limit: pagination.value.pageSize
})
if(!error){
list.value = data.list;
pagination.value.itemCount = data.total;
}
loading.value = false;
}
// 分页改变
const pageChange = (page: number) => {
pagination.value.page = page;
getList();
}
// 搜索用户
const searchUser = () => {
pagination.value.page = 1;
getList();
}
// 获取组织列表
const getOrgList = async (init: boolean = false) => {
treeLoading.value = true;
const {data,error} = await orgListApi();
if(!error){
orgTree.value = data;
if(init){
if(orgTree.value.length > 0){
expandedKeys.value = [orgTree.value[0].id];
selectedKeys.value = [orgTree.value[0].id];
currentOrg.value = orgTree.value[0].id;
getList();
}
}
// 删除当前选中节点时,需要更新选中节点
if(currentNodeType.value==='delete'){
if(currentNode.value.id===currentOrg.value){
expandedKeys.value = [orgTree.value[0].id];
selectedKeys.value = [orgTree.value[0].id];
currentOrg.value = orgTree.value[0].id;
}
getList();
}
}
treeLoading.value = false;
}
// 根据parent_id获取组织名称
const getParentOrg = (parentId: number): string => {
const findNode = (nodes: any[]): any => {
for (const node of nodes) {
if (node.id === parentId) return node;
if (node.children) {
const found = findNode(node.children);
if (found) return found;
}
}
return null;
};
const node = findNode(orgTree.value);
return node?.label || '';
};
// 获取组织名称(新增/编辑)
const getOrg = () => {
if(currentNodeType.value==='edit'){
return getParentOrg(currentNode.value.parent_id)
}
return currentNode.value.label
}
// 获取角色列表
const getRoleList = async () => {
const {data,error} = await roleListApi();
if(!error){
const newArray = data.map((item:any) => ({
label: item.name,
value: item.id
}));
roleList.value = newArray;
}
}
// 通过组织id匹配label(多个)
const getOrgLabel = (orgIds: number[]) => {
const labels = orgIds.map((id: number) => getParentOrg(id));
return labels.join(';');
}
onMounted(() => {
getRoleList();
getOrgList(true);
})
</script>
<template>
<NSpace vertical :size="16">
<!-- 用户管理 -->
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="border mb-3 py-2 px-3" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
<NGi span="24 s:24 m:16">
<div class="h-100% flex items-center gap-2">
<Icon icon="lucide:users-round" class="size-16px" :style="{ color: themeStore.themeColor }" />
<p class="text-[16px] font-600">架构及用户管理</p>
</div>
</NGi>
<NGi span="24 s:24 m:8" class="flex items-center justify-end gap-12px">
<NButton type="primary" size="small" @click="addUser">
<Icon icon="ic:round-plus" class="size-16px" />添加成员
</NButton>
<NPopover trigger="hover">
<template #trigger>
<NButton type="warning" size="small" @click="importUser">
<Icon icon="material-symbols:download" class="size-16px" />导入用户
</NButton>
</template>
<NButton type="primary" ghost size="small" @click="downloadTemplate">下载模板</NButton>
</NPopover>
<NPopover trigger="hover">
<template #trigger>
<NButton text @click="refreshData">
<Icon icon="tdesign:refresh" class="size-14px" :class="restLoading? 'animate-spin':''" />
</NButton>
</template>
<span>重置数据权限</span>
</NPopover>
</NGi>
</NGrid>
</div>
<div class="flex gap-3">
<NSpin :show="treeLoading" size="small" :stroke-width="18">
<div class="border p-3 w-300px" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<NInput v-model:value="keyword" placeholder="请输入人员" clearable @keyup.enter="searchUser">
<template #suffix>
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" @click="searchUser" />
</template>
</NInput>
<!-- -->
<div class="mt-2 scroll">
<NTree
block-line
:data="orgTree"
label-field="label"
key-field="id"
:expanded-keys="expandedKeys"
:selected-keys="selectedKeys"
:render-switcher-icon="renderSwitcherIcon"
:render-suffix="renderSuffix"
selectable
:cancelable="false"
:on-update:expanded-keys="handleExpandKeys"
:on-update:selected-keys="handleSelectKeys"
:node-props="treeProps"
/>
<NDropdown
:show="menuVisible"
:options="menuOptions"
size="small"
:x="menuX"
:y="menuY"
placement="bottom-start"
trigger="manual"
@clickoutside="closeMenu"
@select="handleMenuSelect"
/>
</div>
<p class="mt-2 border-t pt-2 text-[11px] leading-4 text-slate-400">
点击层级查看该层级全员名录右键或点击 ···)可编辑添加子级删除
</p>
</div>
</NSpin>
<div class="flex-1">
<NDataTable
size="small"
:loading="loading"
:single-line="false"
:columns="columns"
:data="list"
:pagination="false"
max-height="calc(100vh - 285px)"
/>
<div class="flex justify-end mt-3">
<NPagination
v-model:page="pagination.page"
:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:on-update:page="pageChange"
>
<template #prefix="{ itemCount }">
{{ itemCount }}
</template>
</NPagination>
</div>
</div>
</div>
<!-- 新增或编辑抽屉 -->
<NDrawer v-model:show="userModal" :width="400" placement="right">
<NDrawerContent :title="currentType === 'add' ? '新增成员' : '编辑成员'" closable>
<NForm ref="formRef" :model="currentUser" :rules="rules" label-placement="left" label-width="auto">
<NFormItem label="姓名" path="real_name">
<NInput
v-model:value="currentUser.real_name"
placeholder="请输入姓名"
/>
</NFormItem>
<NFormItem label="所属部门" path="dept_ids">
<NTreeSelect
v-model:value="currentUser.dept_ids"
multiple
default-expand-all
label-field="label"
key-field="id"
:options="orgTree"
:render-switcher-icon="renderSwitcherIcon"
/>
</NFormItem>
<NFormItem label="性别">
<NRadioGroup v-model:value="currentUser.gender" name="gender">
<NRadio :value="1"></NRadio>
<NRadio :value="2"></NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem label="账号" path="username">
<NInput
v-model:value="currentUser.username"
placeholder="请输入账号"
/>
</NFormItem>
<NFormItem label="手机号码">
<NInput
v-model:value="currentUser.mobile"
placeholder="请输入11位手机号码"
/>
</NFormItem>
<NFormItem label="电子邮箱">
<NInput
v-model:value="currentUser.email"
placeholder="请输入电子邮箱"
/>
</NFormItem>
<NFormItem label="账号角色" path="role_id">
<NSelect
v-model:value="currentUser.role_id"
:options="roleList"
placeholder="请选择账号角色"
/>
</NFormItem>
<NFormItem label="数据权限" path="org_ids">
<NTree
:data="orgTree"
block-line
checkable
cascade
label-field="label"
key-field="id"
:render-switcher-icon="renderSwitcherIcon"
:checked-keys="currentUser.org_ids"
:on-update:checked-keys="handleUpdateChecked"
/>
</NFormItem>
<NFormItem label="状态">
<NRadioGroup v-model:value="currentUser.enabled" name="enabled">
<NRadio :value="true">启用</NRadio>
<NRadio :value="false">禁用</NRadio>
</NRadioGroup>
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-[10px]">
<NButton @click="userModal = false">取消</NButton>
<NButton type="primary" :loading="btnLoading" @click="saveUser">保存</NButton>
</div>
</template>
</NDrawerContent>
</NDrawer>
<!-- 左侧树添加/编辑弹框 -->
<NModal
v-model:show="treeModal"
preset="card"
:title="currentNodeType === 'edit' ? '编辑层级' : '添加子级'"
:auto-focus="false"
:style="{ width: '450px', height: 'auto' }"
:segmented="{ content: true, footer: true }"
>
<template #default>
<NForm ref="formNodeRef" :model="formNode" :rules="treeRules" label-placement="left" label-width="auto">
<NFormItem label="层级名称" path="label">
<NInput
v-model:value="formNode.label"
maxlength="10"
show-count
placeholder="请输入层级名称"
:on-input="convertToPinyin"
/>
</NFormItem>
<NFormItem label="层级简称">
<NInput
v-model:value="formNode.org_code"
maxlength="10"
show-count
placeholder="请输入层级简称"
/>
</NFormItem>
<NFormItem label="所属层级">
<NInput
:value="getOrg()"
:disabled="true"
placeholder=""
/>
</NFormItem>
<NFormItem label="末端层级">
<NRadioGroup v-model:value="formNode.is_bottom" name="is_bottom">
<NRadio :value="1"></NRadio>
<NRadio :value="0"></NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem label="排序">
<NInputNumber
v-model:value="formNode.sort"
class="w-full"
:min="0"
:show-button="false"
placeholder="请输入排序"
/>
</NFormItem>
</NForm>
</template>
<template #footer>
<div class="flex justify-end gap-[10px]">
<NButton @click="treeModal = false">取消</NButton>
<NButton type="primary" :loading="btnLoading" @click="saveNode">确定</NButton>
</div>
</template>
</NModal>
</NCard>
</NSpace>
</template>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 322px);
overflow-y: auto;
}
</style>