Files
tms-erp-web/src/views/system/user.vue
T
b2894lxlx 4c117d87a5 ✨ 角色数据权限配置并移除用户侧数据权限
角色权限弹窗支持按菜单批量设置数据范围;用户管理去掉组织/客商权限配置;角色别名改为角色标识。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 04:05:02 +08:00

1612 lines
51 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.
<template>
<el-row>
<el-col :span="5">
<div class="box">
<el-scrollbar>
<basic-container>
<avue-tree ref="deptTree" :option="treeOption" :data="treeData" @node-click="nodeClick" />
</basic-container>
</el-scrollbar>
</div>
</el-col>
<el-col :span="19">
<basic-container>
<avue-crud
:option="userOption"
v-model:search="search"
:table-loading="loading"
:data="data"
ref="crud"
v-model="form"
:permission="permissionList"
@row-del="rowDel"
@row-update="rowUpdate"
@row-save="rowSave"
:before-open="beforeOpen"
v-model:page="page"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
v-if="permission.user_add && !auditMode"
type="primary"
icon="el-icon-plus"
@click="openUserDialog('add')"
>新 增
</el-button>
<el-button
type="danger"
plain
icon="el-icon-delete"
v-if="permission.user_delete && !auditMode"
@click="handleDelete"
>删除
</el-button>
<el-button
type="primary"
plain
:loading="iamSyncLoading"
v-if="isAdministrator && !auditMode"
@click="handleOaPersonFullSync"
>全量同步
</el-button>
<el-button
type="primary"
plain
:loading="iamSyncLoading"
v-if="isAdministrator && !auditMode"
@click="handleOaPersonIncrementalSync"
>增量同步
</el-button>
<el-button
type="primary"
v-if="isAdministrator && !auditMode"
@click="handleImport"
>导入
</el-button>
<el-button
type="primary"
v-if="isAdministrator && !auditMode"
@click="handleExport"
>导出
</el-button>
</template>
<template #menu-right>
<el-button
v-if="isAdministrator && !auditMode"
icon="el-icon-document"
@click="$refs.authLog.openAuthLog()"
>认证日志
</el-button>
<el-button
v-if="isAdministrator && !auditMode"
icon="el-icon-lock"
@click="$refs.authLock.openLockLog()"
>锁定管理
</el-button>
</template>
<template #account="{ row }">
<el-link type="primary" @click.stop="openUserDialog('view', row)">{{ row.account }}</el-link>
</template>
<template #menu="{ row }">
<el-link
type="primary"
v-if="permission.user_edit && !auditMode"
@click.stop="openUserDialog('edit', row)"
>修改
</el-link>
<el-link
type="primary"
v-if="permission.user_edit && !auditMode"
@click.stop="handleStatus(row)"
>
{{ Number(row.status) === 1 ? '停用' : '启用' }}
</el-link>
</template>
<template #roleName="{ row }">
<el-tag>{{ row.roleName }}</el-tag>
</template>
<template #deptName="{ row }">
<template v-if="formatDeptNames(row.deptName).length">
<el-tag
v-for="(name, index) in formatDeptNames(row.deptName)"
:key="`${row.id}-${index}-${name}`"
class="user-dept-tag"
@click.stop="handleDeptTagClick(row, index)"
>{{ name }}</el-tag>
</template>
<span v-else>-</span>
</template>
</avue-crud>
<el-dialog
v-model="userDialog"
:title="userDialogTitle"
width="90%"
top="5vh"
append-to-body
destroy-on-close
class="user-edit-dialog dialog-form--carded"
>
<template #header>
<span class="dialog-title">{{ userDialogTitle }}</span>
</template>
<section-card title="用户信息">
<el-form ref="userFormRef" :model="form" :rules="isUserViewMode ? {} : userRules" label-position="right" label-width="calc(6em + 24px)">
<el-row :gutter="18">
<el-col :span="6"><el-form-item label="账号名" prop="account"><el-input v-model="form.account" :disabled="isUserViewMode" /></el-form-item></el-col>
<el-col v-if="userDialogMode === 'add'" :span="6">
<el-form-item label="登录密码" prop="password">
<el-input
v-model="form.password"
type="password"
show-password
autocomplete="new-password"
placeholder="大于8位,含字母、数字、特殊字符.!@#$%^&*"
/>
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="姓名" prop="realName"><el-input v-model="form.realName" :disabled="isUserViewMode" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="手机号" prop="phone"><el-input v-model="form.phone" :disabled="isUserViewMode" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="邮箱"><el-input v-model="form.email" :disabled="isUserViewMode" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="性别"><el-select v-model="form.sex" :disabled="isUserViewMode" clearable placeholder="请选择"><el-option label="男" :value="1" /><el-option label="女" :value="2" /><el-option label="未知" :value="3" /></el-select></el-form-item></el-col>
<el-col :span="6"><el-form-item label="岗位" prop="postId"><el-tree-select v-model="form.postId" :data="postList" :props="{ label: 'postName', value: 'id' }" check-strictly :disabled="isUserViewMode" /></el-form-item></el-col>
<el-col :span="6">
<el-form-item label="所属组织" prop="deptId">
<el-cascader
v-model="deptCascaderValue"
:options="permissionDeptTree"
:props="deptCascaderProps"
:disabled="isUserViewMode"
clearable
filterable
collapse-tags
collapse-tags-tooltip
placeholder="请选择所属组织(可多选)"
/>
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="工号"><el-input v-model="form.code" :disabled="isUserViewMode" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="OA登录名"><el-input v-model="form.oaLoginId" :disabled="isUserViewMode" maxlength="128" placeholder="请输入OA登录名" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="人员类别"><el-select v-model="form.personCategory" :disabled="isUserViewMode"><el-option label="内部员工" :value="1" /><el-option label="承运商" :value="2" /></el-select></el-form-item></el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
:disabled="isUserViewMode"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
</section-card>
<el-tabs type="border-card" class="user-permission-tabs">
<el-tab-pane label="已拥有角色">
<el-table :data="formRoleList" border>
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="id" label="角色编号" />
<el-table-column prop="title" label="角色名称" />
<el-table-column v-if="!isUserViewMode" label="操作" width="90"><template #default="{ row }"><el-link type="danger" @click="removeFormRole(row.id)">删除</el-link></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<template #footer>
<span v-if="isUserViewMode" class="dialog-footer">
<el-button @click="userDialog = false">关闭</el-button>
</span>
<span v-else class="dialog-footer">
<el-button type="primary" @click="submitUserForm">保存</el-button>
<el-button @click="userDialog = false">取消</el-button>
<el-button v-if="userDialogMode === 'edit'" @click="resetDialogPassword">重置密码</el-button>
<el-button @click="handleAddRole">添加角色</el-button>
</span>
</template>
</el-dialog>
<!-- 用户角色配置 -->
<el-dialog title="用户角色配置" append-to-body v-model="roleBox" width="345px">
<el-row
justify="space-between"
align="middle"
style="margin-bottom: 12px; background: #f5f7fa; padding: 6px 10px; border-radius: 4px"
>
<span style="display: inline-flex; align-items: center">
<el-switch
v-model="roleLinked"
active-text="节点联动"
size="small"
@change="handleLinkedChange('treeRole')"
/>
<el-tooltip
content="开启后勾选父节点会自动勾选所有子节点,关闭则可独立勾选任意节点"
placement="top"
>
<el-icon style="margin-left: 4px; color: #909399; cursor: pointer"
><el-icon-question-filled
/></el-icon>
</el-tooltip>
</span>
<el-button-group>
<el-button size="small" plain @click="handleSelectAll('treeRole', roleGrantList)"
>全选</el-button
>
<el-button
size="small"
plain
@click="handleInvertSelect('treeRole', roleGrantList, roleLinked)"
>反选</el-button
>
</el-button-group>
</el-row>
<el-tree
:data="roleGrantList"
show-checkbox
:check-strictly="!roleLinked"
default-expand-all
node-key="id"
ref="treeRole"
:default-checked-keys="roleTreeObj"
:props="props"
>
</el-tree>
<template #footer>
<span class="dialog-footer">
<el-button @click="roleBox = false">取 消</el-button>
<el-button type="primary" @click="submitRole">确 定</el-button>
</span>
</template>
</el-dialog>
<!-- 用户数据导入 -->
<el-dialog title="用户数据导入" append-to-body v-model="excelBox" width="555px">
<avue-form :option="excelOption" v-model="excelForm" :upload-after="uploadAfter">
<template #excelTemplate>
<el-button type="primary" @click="handleTemplate">
点击下载<i class="el-icon-download el-icon--right"></i>
</el-button>
</template>
</avue-form>
</el-dialog>
<!-- 管理员修改密码 -->
<el-dialog
title="修改密码"
append-to-body
v-model="passwordBox"
width="520px"
class="dialog-form--carded"
>
<section-card title="密码信息">
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-position="right"
label-width="calc(6em + 24px)"
>
<el-row :gutter="18">
<el-col :span="24">
<el-form-item label="账号名">
<el-input v-model="passwordForm.account" disabled />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="新密码" prop="password">
<el-input
v-model="passwordForm.password"
type="password"
show-password
placeholder="大于8位,含字母、数字、特殊字符.!@#$%^&*"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="确认密码" prop="password2">
<el-input v-model="passwordForm.password2" type="password" show-password />
</el-form-item>
</el-col>
</el-row>
</el-form>
</section-card>
<template #footer>
<span class="dialog-footer">
<el-button @click="passwordBox = false">取 消</el-button>
<el-button type="primary" @click="submitPassword">确 定</el-button>
</span>
</template>
</el-dialog>
<el-dialog
v-model="oaSyncVisible"
width="480px"
append-to-body
:close-on-click-modal="false"
:close-on-press-escape="!oaSyncRunning"
:show-close="!oaSyncRunning"
class="oa-sync-dialog"
@close="closeOaSyncDialog"
>
<template #header>
<span class="dialog-title">{{ oaSyncDialogTitle }}</span>
</template>
<div class="oa-sync-body">
<el-progress
:percentage="oaSyncPercent"
:status="oaSyncProgressStatus"
:stroke-width="12"
/>
<div class="oa-sync-meta">当前进度:第 {{ oaSyncProgress.current || 0 }} / {{ oaSyncTotalPage }} 页</div>
<div class="oa-sync-stats">
<div class="oa-sync-stat">
<span class="oa-sync-stat__label">同步成功</span>
<span class="oa-sync-stat__value oa-sync-stat__value--success">{{ oaSyncProgress.synced }}</span>
</div>
<div class="oa-sync-stat">
<span class="oa-sync-stat__label">跳过</span>
<span class="oa-sync-stat__value oa-sync-stat__value--skip">{{ oaSyncProgress.skipped }}</span>
</div>
<div class="oa-sync-stat">
<span class="oa-sync-stat__label">删除</span>
<span class="oa-sync-stat__value oa-sync-stat__value--remove">{{ oaSyncProgress.removed }}</span>
</div>
</div>
<div v-if="oaSyncProgress.dumpFilePath" class="oa-sync-meta">
原始数据 JSON:{{ oaSyncProgress.dumpFilePath }}
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button v-if="oaSyncRunning" @click="cancelOaSync">取消</el-button>
<el-button v-else type="primary" @click="closeOaSyncDialog">关闭</el-button>
</span>
</template>
</el-dialog>
<!-- 认证日志组件 -->
<auth-log ref="authLog" />
<!-- 认证锁定配置组件 -->
<auth-lock ref="authLock" @lock-success="onLoad(page)" />
</basic-container>
</el-col>
</el-row>
</template>
<script>
import {
getList,
getUser,
remove,
update,
add,
changeStatus,
syncIamAccounts,
grant,
resetPassword,
setPassword,
auditPass,
auditRefuse,
setLeader,
getLeaderList,
} from '@/api/system/user';
import { exportBlob } from '@/api/common';
import { getDeptTree } from '@/api/system/dept';
import { getRoleTree } from '@/api/system/role';
import { getPostList } from '@/api/system/post';
import { mapGetters } from 'vuex';
import { h } from 'vue';
import { getToken } from '@/utils/auth';
import { downloadXls } from '@/utils/util';
import { validateLoginPassword } from '@/utils/validate';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import func from '@/utils/func';
import { sensitive } from '@/utils/sensitive';
import { excelOption, treeOption, userOption } from '@/option/system/user';
import AuthLog from './authlog.vue';
import AuthLock from './authlock.vue';
export default {
components: { AuthLog, AuthLock },
data() {
return {
form: {},
userDialog: false,
userDialogMode: 'add',
search: {},
sensitiveManager: null,
roleBox: false,
roleLinked: false,
roleFormMode: false,
grantUserId: '',
excelBox: false,
passwordBox: false,
initFlag: true,
auditMode: false,
selectionList: [],
query: {},
loading: true,
iamSyncLoading: false,
oaSyncVisible: false,
oaSyncRunning: false,
oaSyncCancelled: false,
oaSyncStatus: 'running',
oaSyncMode: 'full',
oaSyncController: null,
oaSyncProgress: {
current: 0,
size: 50,
total: 0,
synced: 0,
skipped: 0,
removed: 0,
dumpFilePath: '',
},
page: {
pageSize: 10,
currentPage: 1,
total: 0,
},
props: {
label: 'title',
value: 'key',
},
roleGrantList: [],
roleTreeObj: [],
treeDeptId: '',
treeData: [],
permissionDeptTree: [],
postList: [],
treeOption: treeOption,
userOption: userOption(this),
data: [],
passwordForm: {},
passwordRules: {
password: [
{ required: true, validator: validateLoginPassword, trigger: 'blur' },
],
password2: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: this.validatePassword2, trigger: 'blur' },
],
},
userRules: {
account: [{ required: true, message: '请输入账号名', trigger: 'blur' }],
password: [{ required: true, validator: validateLoginPassword, trigger: 'blur' }],
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
deptId: [
{
required: true,
validator: (rule, value, callback) => {
const ids = Array.isArray(value)
? value.filter(item => item !== undefined && item !== null && item !== '')
: value
? String(value).split(',').filter(Boolean)
: [];
if (!ids.length) {
callback(new Error('请选择所属组织'));
return;
}
callback();
},
trigger: 'change',
},
],
},
excelForm: {},
excelOption: excelOption,
};
},
watch: {
'excelForm.isCovered'() {
if (this.excelForm.isCovered !== '') {
const column = this.findColumn(this.excelOption.column, 'excelFile');
column.action = `/blade-system/user/import-user?isCovered=${this.excelForm.isCovered}`;
}
},
},
computed: {
...mapGetters(['userInfo', 'permission', 'isAdministrator']),
oaSyncDialogTitle() {
const modeLabel = this.oaSyncMode === 'incremental' ? '增量同步' : '全量同步';
if (this.oaSyncStatus === 'done') {
return `${modeLabel}完成`;
}
if (this.oaSyncStatus === 'cancelled') {
return `已取消${modeLabel}`;
}
if (this.oaSyncStatus === 'error') {
return `${modeLabel}失败`;
}
return modeLabel;
},
oaSyncTotalPage() {
const total = Number(this.oaSyncProgress.total) || 0;
const size = Number(this.oaSyncProgress.size) || 50;
if (total <= 0) {
return this.oaSyncProgress.current || 0;
}
return Math.max(1, Math.ceil(total / size));
},
oaSyncPercent() {
if (this.oaSyncStatus === 'done') {
return 100;
}
const totalPage = this.oaSyncTotalPage;
const current = Number(this.oaSyncProgress.current) || 0;
if (totalPage <= 0) {
return 0;
}
return Math.min(100, Math.round((current / totalPage) * 100));
},
oaSyncProgressStatus() {
if (this.oaSyncStatus === 'done') {
return 'success';
}
if (this.oaSyncStatus === 'error') {
return 'exception';
}
return undefined;
},
// 所属组织级联配置:多选,checkStrictly 为 true 时任意一级都可选择
deptCascaderProps() {
return {
label: 'title',
value: 'id',
children: 'children',
emitPath: false,
multiple: true,
checkStrictly: true,
expandTrigger: 'click',
};
},
// 所属组织多选级联:form.deptId 为 id 数组(提交时 join)
deptCascaderValue: {
get() {
const ids = Array.isArray(this.form.deptId)
? this.form.deptId
: this.form.deptId
? String(this.form.deptId).split(',').filter(Boolean)
: [];
return ids
.filter(item => item !== undefined && item !== null && item !== '')
.map(item => (typeof item === 'number' ? item : String(item)));
},
set(value) {
this.form.deptId = Array.isArray(value)
? value.filter(item => item !== undefined && item !== null && item !== '')
: value
? [value]
: [];
this.$refs.userFormRef?.validateField('deptId');
},
},
isUserViewMode() {
return this.userDialogMode === 'view';
},
userDialogTitle() {
if (this.userDialogMode === 'add') {
return '新增用户';
}
if (this.userDialogMode === 'view') {
return '用户详情';
}
return '编辑用户';
},
permissionList() {
return {
addBtn: this.validData(this.permission.user_add, false),
viewBtn: false,
delBtn: false,
editBtn: false,
};
},
ids() {
let ids = [];
this.selectionList.forEach(ele => {
ids.push(ele.id);
});
return ids.join(',');
},
formRoleList() {
const selected = new Set(func.split(this.form.roleId || '').map(String));
const roles = [];
const walk = nodes =>
(nodes || []).forEach(node => {
if (selected.has(String(node.id))) roles.push(node);
walk(node.children);
});
walk(this.roleGrantList);
return roles;
},
},
mounted() {
this.initData(this.userInfo.tenantId || this.website.tenantId);
},
created() {
// 创建脱敏工具实例
this.sensitiveManager = sensitive.create({
fields: ['phone', 'email'], // 配置需要脱敏的字段
});
},
methods: {
nodeClick(data) {
const deptId = data?.id;
if (deptId != null && String(this.treeDeptId) === String(deptId)) {
this.treeDeptId = '';
this.page.currentPage = 1;
this.$nextTick(() => {
this.$refs.deptTree?.setCurrentKey?.(null);
});
this.onLoad(this.page);
return;
}
this.treeDeptId = deptId;
this.page.currentPage = 1;
this.onLoad(this.page);
},
formatDeptIds(deptId) {
if (!deptId && deptId !== 0) {
return [];
}
return String(deptId)
.split(',')
.map(item => item.trim())
.filter(Boolean);
},
handleDeptTagClick(row, index) {
const deptIds = this.formatDeptIds(row.deptId);
let deptId = deptIds[index];
if (!deptId) {
const names = this.formatDeptNames(row.deptName);
deptId = this.findDeptIdByName(names[index]);
}
if (!deptId) {
this.$message.warning('未找到对应组织');
return;
}
this.selectDeptOnTree(deptId);
},
findDeptIdByName(name, tree = this.permissionDeptTree) {
if (!name) return null;
for (const node of tree || []) {
if (node.title === name || node.deptName === name || node.label === name) {
return node.id;
}
const matched = this.findDeptIdByName(name, node.children);
if (matched != null) return matched;
}
return null;
},
/**
* 展开左侧懒加载组织树到目标节点并设为选中,同时按该组织筛选用户列表
*/
async selectDeptOnTree(deptId) {
if (deptId == null || deptId === '') return;
if (!this.permissionDeptTree?.length) {
const tenantId = this.userInfo.tenantId || this.website.tenantId;
try {
const res = await getDeptTree(tenantId);
this.permissionDeptTree = res.data.data || [];
} catch (error) {
this.$message.error('组织树加载失败');
return;
}
}
const path = this.findDeptPath(deptId);
if (!path?.length) {
this.$message.warning('未在组织树中找到该部门');
return;
}
const tree = this.$refs.deptTree;
if (!tree) return;
for (let i = 0; i < path.length - 1; i++) {
await this.expandLazyTreeNode(path[i]);
}
await this.$nextTick();
const targetId = path[path.length - 1];
tree.setCurrentKey?.(targetId);
this.$nextTick(() => {
const currentEl = tree.$el?.querySelector?.('.el-tree-node.is-current');
currentEl?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
});
if (String(this.treeDeptId) === String(targetId)) {
return;
}
this.treeDeptId = targetId;
this.page.currentPage = 1;
this.onLoad(this.page);
},
expandLazyTreeNode(key) {
return new Promise(resolve => {
const tree = this.$refs.deptTree;
if (!tree?.getNode) {
resolve();
return;
}
const tryExpand = (retries = 40) => {
const node = tree.getNode(key);
if (!node) {
if (retries <= 0) {
resolve();
return;
}
setTimeout(() => tryExpand(retries - 1), 50);
return;
}
if (node.expanded || node.isLeaf) {
resolve();
return;
}
node.expand(() => resolve());
};
tryExpand();
});
},
handleDropdownCommand(command, row, index) {
if (command === 'setLeader') {
this.handleSetLeader(row);
} else if (command === 'grantRole') {
this.handleGrantForRow(row);
} else if (command === 'authLog') {
this.$refs.authLog.openUserAuthLog(row.id, row.name);
} else if (command === 'lockUser') {
this.$refs.authLock.openLockUser(row.id, row.account);
} else if (command === 'resetPassword') {
this.handleResetForRow(row);
} else if (command === 'setPassword') {
this.handlePasswordForRow(row);
} else if (command === 'delete') {
this.$refs.crud.rowDel(row, index);
}
},
handleResetForRow(row) {
this.$confirm(`确定将账号【${row.account}】的密码重置为随机密码?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return resetPassword(row.id);
})
.then(res => {
this.showResetPassword(res.data.data);
});
},
handleOaPersonFullSync() {
this.$confirm(
'全量同步将按 OA 对齐人员:有登录名的按登录名更新且不修改密码,没有登录名但有工号的也会入库且不回填登录名,部门按 OA 当前部门重绑,OA 中已不存在或已离职的删除。是否继续?',
'全量同步',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(() => {
this.startOaSync(false);
});
},
handleOaPersonIncrementalSync() {
this.$confirm(
'增量同步仅拉取本地最大 OA 更新时间之后有变更的人员:已有的更新且不修改密码,没有的新增,部门按 OA 当前部门重绑,离职人员直接删除。是否继续?',
'增量同步',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info',
}
).then(() => {
this.startOaSync(true);
});
},
startOaSync(incremental = false) {
this.iamSyncLoading = true;
this.oaSyncVisible = true;
this.oaSyncRunning = true;
this.oaSyncCancelled = false;
this.oaSyncStatus = 'running';
this.oaSyncMode = incremental ? 'incremental' : 'full';
this.oaSyncController = new AbortController();
this.oaSyncProgress = {
current: 0,
size: 50,
total: 0,
synced: 0,
skipped: 0,
removed: 0,
dumpFilePath: '',
};
this.runOaSyncPages();
},
isOaSyncCanceledError(error) {
return (
this.oaSyncCancelled ||
error?.code === 'ERR_CANCELED' ||
error?.name === 'CanceledError' ||
error?.name === 'AbortError'
);
},
async runOaSyncPages() {
const size = 50;
const incremental = this.oaSyncMode === 'incremental';
let current = 1;
let modifiedSince = '';
try {
while (!this.oaSyncCancelled) {
const res = await syncIamAccounts(
current,
size,
this.oaSyncController?.signal,
incremental,
modifiedSince
);
const page = res?.data?.data || {};
if (!modifiedSince && page.modifiedSince) {
modifiedSince = page.modifiedSince;
}
this.oaSyncProgress.current = page.current || current;
this.oaSyncProgress.size = page.size || size;
this.oaSyncProgress.total = page.total || 0;
this.oaSyncProgress.synced += page.syncedCount || 0;
this.oaSyncProgress.skipped += page.skippedCount || 0;
this.oaSyncProgress.removed += page.removedCount || 0;
if (page.dumpFilePath) {
this.oaSyncProgress.dumpFilePath = page.dumpFilePath;
}
if (page.finished) {
this.oaSyncStatus = 'done';
break;
}
current += 1;
}
if (this.oaSyncCancelled && this.oaSyncStatus === 'running') {
this.oaSyncStatus = 'cancelled';
}
} catch (error) {
if (this.isOaSyncCanceledError(error)) {
this.oaSyncStatus = 'cancelled';
} else {
this.oaSyncStatus = 'error';
this.$message.error(error?.message || 'OA人员同步失败');
}
} finally {
this.oaSyncRunning = false;
this.iamSyncLoading = false;
if (this.oaSyncStatus === 'done' || this.oaSyncStatus === 'cancelled') {
this.onLoad(this.page, this.query);
}
}
},
cancelOaSync() {
if (!this.oaSyncRunning) {
return;
}
this.oaSyncCancelled = true;
this.oaSyncController?.abort();
},
closeOaSyncDialog() {
if (this.oaSyncRunning) {
this.cancelOaSync();
return;
}
this.oaSyncVisible = false;
},
handleSetLeader(row) {
const tip = row.isLeader === 1 ? '确定取消用户的主管职务?' : '确定设置用户为主管职务?';
const message = row.isLeader === 1 ? '取消主管成功!' : '设置主管成功!';
this.$confirm(tip, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return setLeader(row.id);
})
.then(() => {
this.$message({
type: 'success',
message: message,
});
this.onLoad(this.page);
});
},
initData(tenantId) {
getRoleTree(tenantId).then(res => {
this.roleGrantList = res.data.data;
const column = this.findColumn(this.userOption.group, 'roleId');
column.dicData = res.data.data;
});
getDeptTree(tenantId).then(res => {
this.permissionDeptTree = res.data.data;
const column = this.findColumn(this.userOption.group, 'deptId');
column.dicData = res.data.data;
});
getPostList(tenantId).then(res => {
this.postList = res.data.data;
const column = this.findColumn(this.userOption.group, 'postId');
column.dicData = res.data.data;
});
getLeaderList(tenantId).then(res => {
const column = this.findColumn(this.userOption.group, 'leaderId');
column.dicData = res.data.data;
});
},
// 由部门 id 反查「根 → 该节点」的 id 路径,供级联回显(找不到返回 null)
findDeptPath(id, tree = this.permissionDeptTree, parents = []) {
const target = Array.isArray(id) ? id.at(-1) : id;
if (target === undefined || target === null || target === '') return null;
for (const node of tree || []) {
const path = [...parents, node.id];
if (String(node.id) === String(target)) return path;
const matched = this.findDeptPath(target, node.children, path);
if (matched) return matched;
}
return null;
},
submitRole() {
const roleList = this.$refs.treeRole.getCheckedKeys().join(',');
if (this.roleFormMode) {
this.form.roleId = roleList;
this.roleBox = false;
this.roleFormMode = false;
return;
}
grant(this.grantUserId, roleList).then(() => {
this.roleBox = false;
this.$message({
type: 'success',
message: '操作成功!',
});
this.onLoad(this.page);
});
},
handleAddRole() {
this.roleFormMode = true;
this.roleTreeObj = func.split(this.form.roleId || '');
this.roleLinked = false;
getRoleTree(this.userInfo.tenantId).then(res => {
this.roleGrantList = res.data.data;
this.roleBox = true;
this.$nextTick(() => this.applyCheckedKeys('treeRole', this.roleTreeObj));
});
},
removeFormRole(roleId) {
this.form.roleId = func
.split(this.form.roleId || '')
.filter(id => String(id) !== String(roleId));
},
normalizeSexDisplay(sex) {
if (sex === -1 || sex === '-1' || sex === '' || sex === null || sex === undefined) {
return undefined;
}
return Number(sex);
},
normalizeSexSubmit(sex, clearInvalid = false) {
const value = Number(sex);
if (value === 1 || value === 2 || value === 3) {
return value;
}
return clearInvalid ? -1 : null;
},
formatDeptNames(deptName) {
if (!deptName) {
return [];
}
return String(deptName)
.split(',')
.map(item => item.trim())
.filter(Boolean);
},
handleStatus(row) {
const nextStatus = Number(row.status) === 1 ? 0 : 1;
const actionName = nextStatus === 1 ? '启用' : '停用';
this.$confirm(`确定${actionName}账号【${row.account}】?`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => changeStatus(row.id, nextStatus))
.then(() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
});
},
openUserDialog(mode, row = {}) {
this.userDialogMode = mode;
if (mode === 'add') {
this.form = {
account: '',
password: '',
realName: '',
phone: '',
email: '',
sex: undefined,
postId: '',
deptId: [],
code: '',
personCategory: 1,
remark: '',
roleId: '',
};
this.userDialog = true;
this.$nextTick(() => this.$refs.userFormRef && this.$refs.userFormRef.clearValidate());
return;
}
getUser(row.id).then(res => {
const user = res.data.data;
const deptIds = func.split(user.deptId || '');
this.form = {
...user,
deptId: Array.isArray(deptIds) ? deptIds : [],
postId: func.split(user.postId || '')[0] || '',
roleId: func.join(user.roleId || ''),
personCategory: user.personCategory || 1,
sex: this.normalizeSexDisplay(user.sex),
};
this.sensitiveManager.saveInitialData(this.form);
this.userDialog = true;
this.$nextTick(() => {
this.$refs.userFormRef && this.$refs.userFormRef.clearValidate();
});
});
},
submitUserForm() {
this.$refs.userFormRef.validate(valid => {
if (!valid) return;
const row = {
...this.form,
deptId: func.join(this.form.deptId),
postId: func.join(this.form.postId),
roleId: func.join(this.form.roleId),
leaderId: func.join(this.form.leaderId),
sex: this.normalizeSexSubmit(this.form.sex, this.userDialogMode !== 'add'),
};
if (this.userDialogMode !== 'add') {
delete row.password;
}
const request = this.userDialogMode === 'add' ? add(row) : update(this.sensitiveManager.getSubmitData(row));
request.then(() => {
this.userDialog = false;
this.onLoad(this.page);
this.$message.success('操作成功!');
});
});
},
resetDialogPassword() {
this.handleResetForRow(this.form);
},
rowSave(row, done, loading) {
row.deptId = func.join(row.deptId);
row.roleId = func.join(row.roleId);
row.postId = func.join(row.postId);
row.leaderId = func.join(row.leaderId);
row.sex = this.normalizeSexSubmit(row.sex);
add(row).then(
() => {
this.initFlag = false;
this.onLoad(this.page);
this.$message({
type: 'success',
message: '操作成功!',
});
done();
},
error => {
window.console.log(error);
loading();
}
);
},
rowUpdate(row, index, done, loading) {
row.deptId = func.join(row.deptId);
row.roleId = func.join(row.roleId);
row.postId = func.join(row.postId);
row.leaderId = func.join(row.leaderId);
row.sex = this.normalizeSexSubmit(row.sex, true);
// 获取需要提交的数据
const submitData = this.sensitiveManager.getSubmitData(row);
update(submitData).then(
() => {
this.initFlag = false;
this.onLoad(this.page);
this.$message({
type: 'success',
message: '操作成功!',
});
done();
},
error => {
window.console.log(error);
loading();
}
);
},
rowDel(row) {
this.$confirm('确定将选择数据删除?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return remove(row.id);
})
.then(() => {
this.onLoad(this.page);
this.$message({
type: 'success',
message: '操作成功!',
});
});
},
searchReset() {
this.query = {};
this.treeDeptId = '';
this.$nextTick(() => {
this.$refs.deptTree?.setCurrentKey?.(null);
});
this.onLoad(this.page);
},
searchChange(params, done) {
this.query = params;
this.page.currentPage = 1;
this.onLoad(this.page, params);
done();
},
selectionChange(list) {
this.selectionList = list;
},
selectionClear() {
this.selectionList = [];
this.$refs.crud.toggleSelection();
},
handleDelete() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定将选择数据删除?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return remove(this.ids);
})
.then(() => {
this.onLoad(this.page);
this.$message({
type: 'success',
message: '操作成功!',
});
this.$refs.crud.toggleSelection();
});
},
handleAudit() {
this.auditMode = true;
this.onLoad(this.page, this.query);
},
handleAuditPass() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定将选择数据通过审核?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return auditPass(this.ids);
})
.then(() => {
this.onLoad(this.page);
this.$message({
type: 'success',
message: '操作成功!',
});
this.$refs.crud.toggleSelection();
});
},
handleAuditRefuse() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定将选择数据拒绝审核?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return auditRefuse(this.ids);
})
.then(() => {
this.onLoad(this.page);
this.$message({
type: 'success',
message: '操作成功!',
});
this.$refs.crud.toggleSelection();
});
},
handleAuditBack() {
this.auditMode = false;
this.onLoad(this.page, this.query);
},
handleReset() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定将选择账号密码重置为随机密码?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return resetPassword(this.ids);
})
.then(res => {
this.showResetPassword(res.data.data);
this.$refs.crud.toggleSelection();
});
},
showResetPassword(passwordMap) {
const passwordList = Object.keys(passwordMap || {}).map(account =>
h('div', null, `${account}:${passwordMap[account]}`)
);
this.$alert(h('div', null, passwordList), '密码重置成功,请立即保存', {
confirmButtonText: '我已保存',
type: 'success',
});
},
handleGrant() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.roleTreeObj = [];
this.roleLinked = false;
this.grantUserId = this.ids;
if (this.selectionList.length === 1) {
this.roleTreeObj = this.selectionList[0].roleId.split(',');
}
getRoleTree().then(res => {
this.roleGrantList = res.data.data;
this.roleBox = true;
this.$nextTick(() => this.applyCheckedKeys('treeRole', this.roleTreeObj));
});
},
handleGrantForRow(row) {
this.roleTreeObj = row.roleId ? row.roleId.split(',') : [];
this.roleLinked = false;
this.grantUserId = row.id;
getRoleTree().then(res => {
this.roleGrantList = res.data.data;
this.roleBox = true;
this.$nextTick(() => this.applyCheckedKeys('treeRole', this.roleTreeObj));
});
},
getAllNodeKeys(nodes) {
let keys = [];
nodes.forEach(node => {
keys.push(node.id);
if (node.children && node.children.length > 0) {
keys = keys.concat(this.getAllNodeKeys(node.children));
}
});
return keys;
},
getLeafKeys(nodes) {
let keys = [];
nodes.forEach(node => {
if (!node.children || node.children.length === 0) {
keys.push(node.id);
} else {
keys = keys.concat(this.getLeafKeys(node.children));
}
});
return keys;
},
handleSelectAll(treeRef, dataList) {
const tree = this.$refs[treeRef];
if (!tree) return;
const allKeys = this.getAllNodeKeys(dataList);
tree.setCheckedKeys(allKeys);
},
handleInvertSelect(treeRef, dataList, isLinked) {
const tree = this.$refs[treeRef];
if (!tree) return;
const checkedKeys = new Set(tree.getCheckedKeys());
if (isLinked) {
const leafKeys = this.getLeafKeys(dataList);
const invertedKeys = leafKeys.filter(key => !checkedKeys.has(key));
tree.setCheckedKeys(invertedKeys);
} else {
const allKeys = this.getAllNodeKeys(dataList);
const invertedKeys = allKeys.filter(key => !checkedKeys.has(key));
tree.setCheckedKeys(invertedKeys);
}
},
handleLinkedChange(treeRef) {
const tree = this.$refs[treeRef];
if (!tree) return;
const checkedKeys = tree.getCheckedKeys();
const halfCheckedKeys = tree.getHalfCheckedKeys();
this.$nextTick(() => {
tree.setCheckedKeys([...checkedKeys, ...halfCheckedKeys]);
});
},
// el-tree 会把 setCheckedKeys 的入参记录为默认勾选集并在树数据重载时回放,
// 复用的树实例需在打开配置时整体覆盖勾选状态,避免残留上一次配置的勾选
applyCheckedKeys(treeRef, keys) {
const tree = this.$refs[treeRef];
if (!tree) return;
tree.setCheckedKeys(keys);
},
handlePasswordForRow(row) {
this.passwordForm = {
userId: row.id,
account: row.account,
password: '',
password2: '',
};
this.passwordBox = true;
this.$nextTick(() => {
this.$refs.passwordFormRef && this.$refs.passwordFormRef.clearValidate();
});
},
validatePassword2(rule, value, callback) {
if (value !== this.passwordForm.password) {
callback(new Error('两次输入密码不一致!'));
} else {
callback();
}
},
submitPassword() {
this.$refs.passwordFormRef.validate(valid => {
if (!valid) {
return;
}
setPassword(
this.passwordForm.userId,
this.passwordForm.password,
this.passwordForm.password2
).then(() => {
this.passwordBox = false;
this.$message({ type: 'success', message: '密码修改成功!' });
});
});
},
handleImport() {
this.excelBox = true;
},
uploadAfter(res, done, loading, column) {
window.console.log(column);
this.excelBox = false;
this.refreshChange();
done();
},
handleExport() {
const account = func.toStr(this.search.account);
const realName = func.toStr(this.search.realName);
const phone = func.toStr(this.search.phone);
this.$confirm('是否导出用户数据?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
NProgress.start();
const params = new URLSearchParams({
[this.website.tokenHeader]: getToken(),
});
if (account) params.set('account', account);
if (realName) params.set('realName', realName);
if (phone) params.set('phone', phone);
exportBlob(`/blade-system/user/export-user?${params.toString()}`).then(res => {
downloadXls(res.data, `用户数据表${this.$dayjs().format('YYYY-MM-DD')}.xlsx`);
NProgress.done();
});
});
},
handleTemplate() {
exportBlob(
`/blade-system/user/export-template?${this.website.tokenHeader}=${getToken()}`
).then(res => {
downloadXls(res.data, '用户数据模板.xlsx');
});
},
beforeOpen(done, type) {
if (['edit', 'view'].includes(type)) {
getUser(this.form.id).then(res => {
this.form = res.data.data;
if (this.form.hasOwnProperty('deptId')) {
this.form.deptId = func.split(this.form.deptId);
}
if (this.form.hasOwnProperty('roleId')) {
this.form.roleId = func.split(this.form.roleId);
}
if (this.form.hasOwnProperty('postId')) {
this.form.postId = func.split(this.form.postId);
}
if (this.form.hasOwnProperty('leaderId')) {
this.form.leaderId = func.split(this.form.leaderId);
}
this.form.sex = this.normalizeSexDisplay(this.form.sex);
// 保存初始脱敏数据
this.sensitiveManager.saveInitialData(this.form);
});
}
this.initFlag = true;
done();
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
onLoad(page, params = {}) {
this.loading = true;
const queryParams = { ...this.query, ...params };
if (this.auditMode) {
queryParams.status = 0;
}
getList(page.currentPage, page.pageSize, queryParams, this.treeDeptId).then(res => {
const data = res.data.data;
this.page.total = data.total;
this.data = data.records;
this.loading = false;
this.selectionClear();
});
},
},
};
</script>
<style>
.box {
height: 800px;
}
.el-scrollbar {
height: 100%;
}
.box .el-scrollbar__wrap {
overflow: scroll;
}
.user-permission-tabs {
width: 100%;
margin-top: 8px;
}
.user-edit-dialog .dialog-title,
.oa-sync-dialog .dialog-title {
display: inline-flex;
align-items: center;
font-size: 16px;
font-weight: 600;
}
.user-edit-dialog .dialog-title::before,
.oa-sync-dialog .dialog-title::before {
width: 4px;
height: 18px;
margin-right: 8px;
background: #409eff;
content: '';
}
.oa-sync-body {
padding: 8px 4px 0;
}
.oa-sync-meta {
margin-top: 12px;
color: #606266;
font-size: 13px;
}
.oa-sync-stats {
display: flex;
gap: 16px;
margin-top: 16px;
}
.oa-sync-stat {
flex: 1;
padding: 12px 16px;
background: #fafafa;
border: 1px solid #eff1f7;
border-radius: 4px;
}
.oa-sync-stat__label {
display: block;
color: #909399;
font-size: 12px;
}
.oa-sync-stat__value {
display: block;
margin-top: 6px;
font-size: 22px;
font-weight: 600;
line-height: 1.2;
}
.oa-sync-stat__value--success {
color: #409eff;
}
.oa-sync-stat__value--skip {
color: #909399;
}
.oa-sync-stat__value--remove {
color: #f56c6c;
}
.user-edit-dialog .el-form-item {
margin-bottom: 24px;
}
.user-edit-dialog .el-form-item__label {
white-space: nowrap;
}
.user-edit-dialog .el-radio-group {
white-space: nowrap;
}
.full-width {
width: 100%;
}
.user-permission-tabs .el-tabs__header {
margin-bottom: 12px;
}
.permission-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.customer-toolbar {
justify-content: flex-start;
}
.user-dept-tag {
cursor: pointer;
}
.user-dept-tag + .user-dept-tag {
margin-left: 6px;
}
.avue-dialog .el-form-item__label {
white-space: nowrap;
}
.avue-dialog .el-radio-group {
white-space: nowrap;
}
</style>