czh-20260616-更改协作人选人组件

This commit is contained in:
zhihao
2026-06-16 18:44:49 +08:00
parent 218f994857
commit 74b8b33d6e
8 changed files with 951 additions and 531 deletions
@@ -0,0 +1,202 @@
<template>
<div>
<a-row v-if="!hideTrigger" class="j-select-row" type="flex" :gutter="8">
<a-col class="left">
<a-select
:value="displayValue"
:placeholder="'请选择人员'"
:disabled="disabled"
:open="false"
mode="multiple"
:maxTagCount="3"
style="width: 100%"
/>
</a-col>
<a-col class="right">
<a-button type="primary" @click="open()" :disabled="disabled">选择</a-button>
</a-col>
</a-row>
<a-modal
:width="1200"
:title="title"
v-model:open="visible"
centered
:confirm-loading="confirmLoading"
@ok="handleOk"
@cancel="handleCancel"
:mask-closable="false"
>
<div v-if="dataLoading" style="height: 556px; display: flex; align-items: center; justify-content: center">
<a-spin tip="加载中..." />
</div>
<SzCollabUserSelect
v-show="!dataLoading"
ref="selectRef"
:secLevel="secLevel"
:departId="innerDepartId"
:role="role"
:isSpecial="isSpecial"
:editableUsers="innerEditable"
:readonlyUsers="innerReadonly"
:excludedUserIds="excludedUserIds"
@update:editableUsers="(v) => (tempEditable = v)"
@update:readonlyUsers="(v) => (tempReadonly = v)"
/>
<template #footer>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="confirmLoading" @click="handleOk">确定</a-button>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { defHttp } from '/@/utils/http/axios';
import { useUserStore } from '/@/store/modules/user';
import SzCollabUserSelect from './SzCollabUserSelect.vue';
import type { UserItem, CollabConfirmResult } from './types';
const props = withDefaults(
defineProps<{
editableUserIds?: string;
readonlyUserIds?: string;
departId?: string;
secLevel?: any;
role?: string | number;
isSpecial?: number;
disabled?: boolean;
title?: string;
hideTrigger?: boolean;
excludedUserIds?: string;
}>(),
{
editableUserIds: '',
readonlyUserIds: '',
departId: '',
secLevel: 0,
role: 1,
isSpecial: 0,
disabled: false,
title: '选择协作人',
hideTrigger: false,
excludedUserIds: '',
}
);
const emit = defineEmits(['confirm']);
const userStore = useUserStore();
const selectRef = ref<InstanceType<typeof SzCollabUserSelect> | null>(null);
const visible = ref(false);
const dataLoading = ref(false);
const confirmLoading = ref(false);
let loadSeq = 0;
const innerEditable = ref<UserItem[]>([]);
const innerReadonly = ref<UserItem[]>([]);
const tempEditable = ref<UserItem[]>([]);
const tempReadonly = ref<UserItem[]>([]);
const innerDepartId = computed(() => {
return props.departId || userStore.getUserInfo?.orgCode || '';
});
const displayValue = computed(() => {
const names: string[] = [];
for (const u of innerEditable.value) names.push(`[编]${u.realname}`);
for (const u of innerReadonly.value) names.push(`[阅]${u.realname}`);
return names;
});
async function loadUsers(valueStr: string, permission: '2' | '3') {
if (!valueStr) return [];
try {
const res: any = await defHttp.get({
url: '/sys/user/queryUserComponentData',
params: { id: valueStr, isMultiTranslate: 'true', pageNo: 1, pageSize: 9999 },
});
return (res.records || res) as UserItem[];
} catch {
return [];
}
}
async function loadInitialUsers() {
const [editUsers, readUsers] = await Promise.all([
loadUsers(props.editableUserIds, '2'),
loadUsers(props.readonlyUserIds, '3'),
]);
innerEditable.value = editUsers;
innerReadonly.value = readUsers;
}
async function open(editIds?: string, readIds?: string) {
if (props.disabled) return;
visible.value = true;
confirmLoading.value = false;
const hasExplicitData = editIds !== undefined || readIds !== undefined;
const hasPropsData = props.editableUserIds || props.readonlyUserIds;
if (!hasExplicitData && !hasPropsData) {
dataLoading.value = true;
return;
}
dataLoading.value = true;
const seq = ++loadSeq;
const loadUsersTask = (async () => {
let editU: UserItem[] = [];
let readU: UserItem[] = [];
if (hasExplicitData) {
[editU, readU] = await Promise.all([loadUsers(editIds || ''), loadUsers(readIds || '')]);
} else {
[editU, readU] = await Promise.all([loadUsers(props.editableUserIds), loadUsers(props.readonlyUserIds)]);
}
if (seq !== loadSeq) return;
innerEditable.value = editU;
innerReadonly.value = readU;
tempEditable.value = [...editU];
tempReadonly.value = [...readU];
})();
const tableTask = selectRef.value?.resetSearch?.();
await Promise.all([loadUsersTask, tableTask]);
if (seq !== loadSeq) return;
dataLoading.value = false;
}
function handleOk() {
const result: CollabConfirmResult = selectRef.value?.getResult() || {
editableIds: '', editableNames: '', readonlyIds: '', readonlyNames: '',
};
emit('confirm', result);
visible.value = false;
}
function handleCancel() {
visible.value = false;
}
defineExpose({ open, loadInitialUsers });
</script>
<style lang="less" scoped>
.j-select-row {
@width: 82px;
.left {
width: calc(100% - @width - 8px);
}
.right {
width: @width;
}
:deep(.ant-select-search__field) {
display: none !important;
}
}
</style>
@@ -0,0 +1,536 @@
<template>
<a-row :gutter="0" style="height: 556px">
<!-- 部门树 -->
<a-col :span="6" style="height: 100%; border-right: 1px solid #e8e8e8; padding: 0 12px; display: flex; flex-direction: column">
<a-input-search v-model:value="searchDepartValue" style="margin-bottom: 12px; margin-top: 0" placeholder="请输入部门名称" />
<a-directory-tree
selectable
v-model:selectedKeys="selectedDepIds"
:check-strictly="true"
:dropdown-style="{ maxHeight: '200px', overflow: 'auto' }"
:tree-data="filterDepartTreeData"
:expand-action="false"
v-model:expandedKeys="expandedKeys"
@select="onDepSelect"
style="height: 500px; overflow: auto"
/>
</a-col>
<!-- 人员表格 -->
<a-col :span="10" style="height: 100%; padding: 0 12px; border-right: 1px solid #e8e8e8; display: flex; flex-direction: column">
<div style="margin-bottom: 12px; margin-top: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<a-input-search
style="flex: 1; min-width: 120px"
placeholder="输入姓名"
v-model:value="queryParam.realname"
@search="onSearch"
/>
<a-button @click="searchReset(1)">重置</a-button>
<a-button type="primary" @click="selectAll">全选</a-button>
<a-button type="danger" @click="unselectAll">清空</a-button>
</div>
<div style="margin-bottom: 8px; display: flex; align-items: center; gap: 6px">
<span style="font-size: 12px; color: #666">当前选择模式</span>
<a-radio-group v-model:value="activePermission" size="small" button-style="solid">
<a-radio-button value="2" style="padding: 0 8px">&nbsp;可编辑</a-radio-button>
<a-radio-button value="3" style="padding: 0 8px">&nbsp;可阅读</a-radio-button>
</a-radio-group>
</div>
<div style="flex: 1; min-height: 0; overflow: auto">
<a-empty v-if="selectedDepIds.length === 0" description="请先选择部门!" />
<a-table
v-else
:scroll="{ y: 390 }"
size="middle"
row-key="id"
:columns="columns"
:data-source="dataSource"
:pagination="ipagination"
:row-selection="{ selectedRowKeys: selectedRowKeys, onSelectAll: onSelectAll, onSelect: onSelect, type: 'checkbox', preserveSelectedRowKeys: true, getCheckboxProps: getCheckboxProps }"
:loading="loading"
@change="handleTableChange"
:custom-row="customRow"
/>
</div>
</a-col>
<!-- 已选列表 -->
<a-col :span="8" style="height: 100%; padding: 0 12px; display: flex; flex-direction: column">
<div style="margin-bottom: 12px; margin-top: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<span style="font-weight: 500">已选 ({{ selectedRows.length }})</span>
<a-button type="danger" @click="clearSelected">清空</a-button>
</div>
<div style="flex: 1; overflow: auto; min-height: 0">
<a-empty v-if="selectedRows.length === 0" description="暂无已选用户" :image-style="{ height: '40px' }" />
<div v-else class="selected-list">
<a-button-group v-for="(user) in selectedRows" :key="user.id" class="small-space selected-item">
<a-dropdown trigger="click">
<a-button size="small" :class="user.permission === '2' ? 'btn-edit' : 'btn-read'">
{{ user.permission === '2' ? '编' : '阅' }}
</a-button>
<template #overlay>
<a-menu @click="({ key }) => onMoveUserPermission(user, key as '2' | '3')">
<a-menu-item key="2" :class="{ 'active-menu-key': user.permission === '2' }">
<span> 可编辑</span>
<CheckOutlined v-if="user.permission === '2'" class="check-icon" />
</a-menu-item>
<a-menu-item key="3" :class="{ 'active-menu-key': user.permission === '3' }">
<span> 可阅读</span>
<CheckOutlined v-if="user.permission === '3'" class="check-icon" />
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button size="small" :class="user.permission === '2' ? 'btn-edit' : 'btn-read'">
{{ user.realname }}
</a-button>
<a-button size="small" :class="user.permission === '2' ? 'btn-edit' : 'btn-read'" @click="removeSingle(user)">
<CloseOutlined />
</a-button>
</a-button-group>
</div>
</div>
<div v-if="selectedRows.length > 0" style="padding: 8px 0; font-size: 12px; color: #888; border-top: 1px solid #f0f0f0">
<span style="color: #1677ff">编辑 {{ editableCount }} </span>
<span style="margin: 0 8px">|</span>
<span style="color: #52c41a">阅读 {{ readonlyCount }} </span>
</div>
</a-col>
</a-row>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons-vue';
import { queryTreeList } from '/@/api/common/api';
import { defHttp } from '/@/utils/http/axios';
import { filterObj } from '/@/utils/common/compUtils';
import { deleteIfExist, pushIfNotExist, filterDepartTree, recurTree } from './types';
import type { UserItem, DepartTreeNode } from './types';
const props = withDefaults(
defineProps<{
secLevel: any;
editableUsers?: UserItem[];
readonlyUsers?: UserItem[];
departId: string;
role: string | number;
isSpecial?: number;
excludedUserIds?: string;
}>(),
{
editableUsers: () => [],
readonlyUsers: () => [],
isSpecial: 0,
excludedUserIds: '',
}
);
const excludedIdSet = computed(() => {
if (!props.excludedUserIds) return new Set<string>();
return new Set(props.excludedUserIds.split(',').filter(Boolean));
});
const emit = defineEmits(['update:editableUsers', 'update:readonlyUsers']);
type Permission = '2' | '3';
const activePermission = ref<Permission>('2');
const searchDepartValue = ref('');
const queryParam = reactive<Record<string, any>>({ realname: '' });
const dataSource = ref<UserItem[]>([]);
const selectedDepIds = ref<string[]>([]);
const editableList = ref<UserItem[]>([]);
const readonlyList = ref<UserItem[]>([]);
const departTree = ref<DepartTreeNode[]>([]);
const fullDepartTree = ref<DepartTreeNode[]>([]);
const loading = ref(false);
const expandedKeys = ref<string[]>([]);
const selectedRows = computed<(UserItem & { permission: Permission })[]>(() => {
return [
...editableList.value.map((u) => ({ ...u, permission: '2' as Permission })),
...readonlyList.value.map((u) => ({ ...u, permission: '3' as Permission })),
];
});
const editableCount = computed(() => editableList.value.length);
const readonlyCount = computed(() => readonlyList.value.length);
const currentPermissionRows = computed(() => (activePermission.value === '2' ? editableList.value : readonlyList.value));
const otherPermissionIds = computed(() => {
const other = activePermission.value === '2' ? readonlyList.value : editableList.value;
return new Set(other.map((u) => u.id));
});
const selectedRowKeys = computed(() => currentPermissionRows.value.map((item) => item.id));
function findDepartIdByOrgCode(tree: DepartTreeNode[], orgCode: string): string | null {
for (const node of tree) {
if (node.orgCode === orgCode) return node.id;
if (node.children && node.children.length > 0) {
const found = findDepartIdByOrgCode(node.children, orgCode);
if (found) return found;
}
}
return null;
}
function resolveDepartId(departIdOrOrgCode: string): string {
if (!departIdOrOrgCode) return departIdOrOrgCode;
const uuidPattern = /^[0-9a-f]{32}$/i;
if (uuidPattern.test(departIdOrOrgCode.replace(/-/g, ''))) return departIdOrOrgCode;
const realId = findDepartIdByOrgCode(fullDepartTree.value, departIdOrOrgCode);
if (realId) return realId;
return departIdOrOrgCode;
}
const columns = [
{ title: '用户姓名', align: 'center' as const, dataIndex: 'realname' },
{ title: '人员密级', align: 'center' as const, dataIndex: 'userSecurityLevel_dictText' },
];
const ipagination = reactive({
current: 1,
pageSize: 7,
showQuickJumper: true,
total: 0,
});
const isorter = reactive({
column: 'sortno',
order: 'asc',
});
const url = {
pageUserDepart: '/sys/user/queryUserComponentData',
};
const filterDepartTreeData = computed(() => {
if (!searchDepartValue.value) return departTree.value;
return filterDepartTree(departTree.value, searchDepartValue.value);
});
function getCheckboxProps(record: UserItem) {
return { disabled: otherPermissionIds.value.has(record.id) || excludedIdSet.value.has(record.id) };
}
function syncFromProps() {
editableList.value = (props.editableUsers || []).filter((u) => !excludedIdSet.value.has(u.id));
readonlyList.value = (props.readonlyUsers || []).filter((u) => !excludedIdSet.value.has(u.id));
}
watch(() => props.editableUsers, syncFromProps);
watch(() => props.readonlyUsers, syncFromProps);
watch(
() => props.departId,
() => { queryDepartTree(); },
{ immediate: true }
);
watch(
() => props.role,
() => { queryDepartTree(); }
);
watch(
() => props.secLevel,
() => {
if (selectedDepIds.value.length > 0) {
loadData();
}
}
);
function initData() {
const depId = props.departId;
if (depId) {
const resolvedId = resolveDepartId(depId);
selectedDepIds.value = [resolvedId];
loadData();
}
syncFromProps();
}
async function loadData(arg?: number) {
if (selectedDepIds.value.length > 0) {
if (arg === 1) {
ipagination.current = 1;
}
const params = getQueryParams();
loading.value = true;
try {
const res: any = await defHttp.get({ url: url.pageUserDepart, params });
dataSource.value = (res.records || []).filter((user: UserItem) => {
const userLevel = user.userSecurityLevel;
const docLevel = props.secLevel;
return userLevel !== null && userLevel > docLevel;
});
ipagination.total = res.total;
} finally {
loading.value = false;
}
}
}
function getQueryParams() {
const param = Object.assign({}, queryParam, isorter);
param.searchSecurityLevel = props.secLevel;
param.field = getQueryField();
param.pageNo = ipagination.current;
param.pageSize = ipagination.pageSize;
param.departId = selectedDepIds.value.join(',');
param.isSpecial = props.isSpecial;
return filterObj(param);
}
function getQueryField() {
let str = 'id,';
for (let a = 0; a < columns.length; a++) {
str += ',' + columns[a].dataIndex;
}
return str;
}
function searchReset(num?: number) {
if (num !== 0) {
queryParam.realname = '';
loadData(1);
}
}
function handleTableChange(pagination: any, _filters: any, sorter: any) {
if (Object.keys(sorter).length > 0) {
isorter.column = sorter.field;
isorter.order = sorter.order === 'ascend' ? 'asc' : 'desc';
}
Object.assign(ipagination, pagination);
loadData();
}
function onDepSelect(keys: string[]) {
if (keys.length > 0 && keys[0] != null) {
selectedDepIds.value = [keys[0]];
loadData(1);
}
}
function removeFromPermission(userId: string, list: UserItem[]) {
const idx = list.findIndex((u) => u.id === userId);
if (idx >= 0) list.splice(idx, 1);
}
function addToPermission(user: UserItem, list: UserItem[]) {
if (!list.find((u) => u.id === user.id)) {
list.push(user);
}
}
function moveUser(user: UserItem, fromList: UserItem[], toList: UserItem[]) {
removeFromPermission(user.id, fromList);
addToPermission(user, toList);
}
function onMoveUserPermission(user: UserItem & { permission?: Permission }, newPermission: Permission) {
if (newPermission === '2') {
removeFromPermission(user.id, readonlyList.value);
addToPermission(user, editableList.value);
} else {
removeFromPermission(user.id, editableList.value);
addToPermission(user, readonlyList.value);
}
emitUpdate();
}
function onSelect(record: UserItem, selected: boolean) {
const currentList = activePermission.value === '2' ? editableList.value : readonlyList.value;
if (selected) {
addToPermission(record, currentList);
// Also remove from other list (move)
const otherList = activePermission.value === '2' ? readonlyList.value : editableList.value;
removeFromPermission(record.id, otherList);
} else {
removeFromPermission(record.id, currentList);
}
emitUpdate();
}
function onSelectAll(selected: boolean, _allRecords: UserItem[], changeRecords: UserItem[]) {
const changedRows = Array.isArray(changeRecords) ? changeRecords.filter(Boolean) : [];
const currentList = activePermission.value === '2' ? editableList.value : readonlyList.value;
const otherList = activePermission.value === '2' ? readonlyList.value : editableList.value;
if (selected) {
changedRows.forEach((item) => {
addToPermission(item, currentList);
removeFromPermission(item.id, otherList);
});
} else {
changedRows.forEach((item) => {
removeFromPermission(item.id, currentList);
});
}
emitUpdate();
}
function onSearch() {
loadData(1);
}
async function queryDepartTree() {
expandedKeys.value = [];
departTree.value = [];
try {
const res: any = await queryTreeList();
const arr = [...res];
fullDepartTree.value = [...arr];
if (props.role === 1 || props.role === '1') {
departTree.value = [...arr];
} else {
const resolvedDepartId = resolveDepartId(props.departId);
const tree = recurTree(arr, (node) => {
if (node.id === resolvedDepartId) return [node];
return [];
});
departTree.value = [...tree];
}
departTree.value.forEach((item) => {
expandedKeys.value.push(item.key);
});
await initData();
} catch (e) {
console.error('queryDepartTree error:', e);
}
}
function selectAll() {
const currentList = activePermission.value === '2' ? editableList.value : readonlyList.value;
const otherList = activePermission.value === '2' ? readonlyList.value : editableList.value;
dataSource.value.forEach((item) => {
if (excludedIdSet.value.has(item.id)) return;
addToPermission(item, currentList);
removeFromPermission(item.id, otherList);
});
emitUpdate();
}
function unselectAll() {
const currentList = activePermission.value === '2' ? editableList.value : readonlyList.value;
dataSource.value.forEach((item) => {
if (excludedIdSet.value.has(item.id)) return;
removeFromPermission(item.id, currentList);
});
emitUpdate();
}
function clearSelected() {
editableList.value = [];
readonlyList.value = [];
emitUpdate();
}
function removeSingle(user: { id: string; permission: Permission }) {
if (user.permission === '2') {
removeFromPermission(user.id, editableList.value);
} else {
removeFromPermission(user.id, readonlyList.value);
}
emitUpdate();
}
function emitUpdate() {
const editableIds = editableList.value.map((u) => u.id).join(',');
const editableNames = editableList.value.map((u) => u.realname).join(',');
const readonlyIds = readonlyList.value.map((u) => u.id).join(',');
const readonlyNames = readonlyList.value.map((u) => u.realname).join(',');
emit('update:editableUsers', editableList.value);
emit('update:readonlyUsers', readonlyList.value);
}
function customRow(record: UserItem) {
return {
onClick: () => {
if (excludedIdSet.value.has(record.id)) return;
if (otherPermissionIds.value.has(record.id)) {
onMoveUserPermission(record, activePermission.value);
} else if (currentPermissionRows.value.find((u) => u.id === record.id)) {
const list = activePermission.value === '2' ? editableList.value : readonlyList.value;
removeFromPermission(record.id, list);
emitUpdate();
} else {
const list = activePermission.value === '2' ? editableList.value : readonlyList.value;
addToPermission(record, list);
emitUpdate();
}
},
};
}
async function resetSearch() {
searchDepartValue.value = '';
queryParam.realname = '';
ipagination.current = 1;
const depId = props.departId;
if (depId && selectedDepIds.value.length === 0) {
selectedDepIds.value = [resolveDepartId(depId)];
}
syncFromProps();
await loadData(1);
}
defineExpose({
clearSelected,
resetSearch,
getResult: (): { editableIds: string; editableNames: string; readonlyIds: string; readonlyNames: string } => ({
editableIds: editableList.value.map((u) => u.id).join(','),
editableNames: editableList.value.map((u) => u.realname).join(','),
readonlyIds: readonlyList.value.map((u) => u.id).join(','),
readonlyNames: readonlyList.value.map((u) => u.realname).join(','),
}),
});
</script>
<style scoped>
.ant-table-tbody .ant-table-row td {
padding-top: 10px;
padding-bottom: 10px;
}
.small-space {
margin: 0 4px 4px 0;
}
.selected-list {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
}
.btn-edit {
color: #1677ff;
border-color: #1677ff;
background: #e6f4ff;
}
.btn-edit:hover {
color: #4096ff;
border-color: #4096ff;
}
.btn-read {
color: #52c41a;
border-color: #52c41a;
background: #f6ffed;
}
.btn-read:hover {
color: #73d13d;
border-color: #73d13d;
}
.active-menu-key {
color: #1677ff;
}
.check-icon {
margin-left: auto;
color: #1677ff;
font-size: 12px;
}
</style>
@@ -0,0 +1,79 @@
export interface UserItem {
id: string;
username: string;
realname: string;
orgCodeTxt?: string;
userSecurityLevel?: number;
userSecurityLevel_dictText?: string;
sex_dictText?: string;
sortno?: number;
departId?: string;
departName?: string;
}
export interface CollabUserItem extends UserItem {
permission: '2' | '3';
}
export interface DepartTreeNode {
id: string;
key: string;
value: string;
title: string;
isLeaf?: boolean;
departType?: string | number;
distributable?: number;
disableCheckbox?: boolean;
children?: DepartTreeNode[];
description?: string;
parentId?: string;
orgCode?: string;
}
export interface CollabConfirmResult {
editableIds: string;
editableNames: string;
readonlyIds: string;
readonlyNames: string;
}
export function deleteIfExist<T extends Record<string, any>>(array: T[], value: T, key: string): void {
const idx = array.findIndex((item) => item[key] === value[key]);
if (idx >= 0) {
array.splice(idx, 1);
}
}
export function pushIfNotExist<T extends Record<string, any>>(array: T[], value: T, key: string): void {
const idx = array.findIndex((item) => item[key] === value[key]);
if (idx < 0) {
array.push(value);
}
}
export function filterDepartTree<T extends Record<string, any>>(departTree: T[], searchValue: string): T[] {
let filtered: T[] = [];
departTree.forEach((item) => {
if (item.title && item.title.includes(searchValue)) {
filtered.push(Object.assign({}, item, { children: null, isLeaf: true }));
}
if (item.children) {
const result = filterDepartTree(item.children, searchValue);
if (result.length > 0) {
filtered = filtered.concat(result);
}
}
});
return filtered;
}
export function recurTree<T extends Record<string, any>, R>(tree: T[], callback: (node: T) => R[]): R[] {
let res: R[] = [];
tree.forEach((item) => {
res = res.concat(callback(item));
if (item.children) {
res = res.concat(recurTree(item.children, callback));
}
});
return res;
}