Files
cn-rdms-web/src/components/custom/attendee-user-picker.vue

1019 lines
25 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { fetchGetDeptSimpleList, fetchGetUserSimpleList } from '@/service/api';
import { buildMenuTree } from '@/views/system/shared/menu-tree';
import IconEpOfficeBuilding from '~icons/ep/office-building';
import IconEpUser from '~icons/ep/user';
defineOptions({ name: 'AttendeeUserPicker' });
type Source = 'team' | 'all' | 'dept';
type TreeCheckState = 'none' | 'partial' | 'all';
interface AttendeeItem {
userId: string;
nickname: string;
}
interface TeamUser {
userId: string;
userNickname: string;
roleName?: string | null;
}
interface Props {
teamOptions?: TeamUser[];
teamTabLabel?: string;
showDeptTab?: boolean;
showAllUsersTab?: boolean;
}
const props = defineProps<Props>();
const model = defineModel<AttendeeItem[]>({ default: () => [] });
const hasTeamTab = computed(() => props.teamOptions !== undefined);
const hasDeptTab = computed(() => props.showDeptTab !== false);
const hasAllUsersTab = computed(() => props.showAllUsersTab !== false);
const sourceTabs = computed(() => {
const tabs: Array<{ key: Source; label: string }> = [];
if (hasTeamTab.value) {
tabs.push({ key: 'team', label: props.teamTabLabel || '团队' });
}
if (hasDeptTab.value) {
tabs.push({ key: 'dept', label: '部门' });
}
if (hasAllUsersTab.value) {
tabs.push({ key: 'all', label: '全部用户' });
}
return tabs;
});
const getInitialSource = (): Source => {
if (hasTeamTab.value) return 'team';
if (hasDeptTab.value) return 'dept';
return 'all';
};
const source = ref<Source>(getInitialSource());
const popoverVisible = ref(false);
const currentDeptId = ref<string | null>(null);
const treeSearch = ref('');
const userSearch = ref('');
const hideSelected = ref(false);
const allUsers = ref<Api.SystemManage.UserSimple[]>([]);
const deptTree = ref<Api.SystemManage.DeptSimple[]>([]);
const allUsersLoading = ref(false);
const deptTreeLoading = ref(false);
let allUsersLoaded = false;
let deptTreeLoaded = false;
const selectedIdSet = computed(() => new Set(model.value.map(item => item.userId)));
const selectedCount = computed(() => model.value.length);
const referenceSelectedItems = computed(() => model.value.slice(0, 3));
const referenceOverflowCount = computed(() => Math.max(model.value.length - referenceSelectedItems.value.length, 0));
const teamUsers = computed<Api.SystemManage.UserSimple[]>(() =>
(props.teamOptions ?? []).map(item => ({
id: item.userId,
nickname: item.userNickname,
deptName: item.roleName ?? null
}))
);
const mergedUserMap = computed(() => {
const map = new Map<string, Api.SystemManage.UserSimple>();
for (const user of allUsers.value) {
map.set(user.id, user);
}
for (const user of teamUsers.value) {
if (!map.has(user.id)) {
map.set(user.id, user);
}
}
return map;
});
const currentSourceUsers = computed(() => {
if (!isSourceAvailable(source.value)) {
return [];
}
if (source.value === 'team') {
return teamUsers.value;
}
if (source.value === 'all') {
return allUsers.value;
}
if (!currentDeptId.value) {
return allUsers.value;
}
const node = findDeptNode(deptTree.value, currentDeptId.value);
if (!node) {
return allUsers.value;
}
const deptIds = new Set(collectDeptIds(node));
return allUsers.value.filter(user => user.deptId && deptIds.has(user.deptId));
});
const filteredUsers = computed(() => {
const keyword = userSearch.value.trim().toLowerCase();
let list = currentSourceUsers.value;
if (hideSelected.value) {
list = list.filter(item => !selectedIdSet.value.has(item.id));
}
if (!keyword) {
return list;
}
return list.filter(item => {
return (
item.nickname.toLowerCase().includes(keyword) ||
(item.username ?? '').toLowerCase().includes(keyword) ||
(item.deptName ?? '').toLowerCase().includes(keyword)
);
});
});
const deptTreeProps = { children: 'children', label: 'name' } as const;
async function ensureAllUsers() {
if (allUsersLoaded) {
return;
}
allUsersLoading.value = true;
const { error, data } = await fetchGetUserSimpleList();
allUsersLoading.value = false;
if (!error && data) {
allUsers.value = data;
allUsersLoaded = true;
}
}
async function ensureDeptTree() {
if (deptTreeLoaded) {
return;
}
deptTreeLoading.value = true;
const { error, data } = await fetchGetDeptSimpleList();
deptTreeLoading.value = false;
if (!error && data) {
deptTree.value = buildMenuTree(data);
deptTreeLoaded = true;
}
}
async function switchSource(next: Source) {
if (!isSourceAvailable(next)) {
return;
}
if (source.value === next) {
return;
}
source.value = next;
userSearch.value = '';
treeSearch.value = '';
currentDeptId.value = null;
if (next === 'all') {
await ensureAllUsers();
}
if (next === 'dept') {
await Promise.all([ensureAllUsers(), ensureDeptTree()]);
}
}
function isSourceAvailable(next: Source) {
return sourceTabs.value.some(tab => tab.key === next);
}
async function syncSourceWithAvailableTabs() {
if (isSourceAvailable(source.value)) {
return;
}
const fallback = sourceTabs.value[0]?.key;
if (fallback) {
await switchSource(fallback);
}
}
async function preloadCurrentSource() {
if (!isSourceAvailable(source.value)) {
return;
}
if (source.value === 'all') {
await ensureAllUsers();
}
if (source.value === 'dept') {
await Promise.all([ensureAllUsers(), ensureDeptTree()]);
}
}
function handleVisibleChange(value: boolean) {
popoverVisible.value = value;
if (value) {
preloadCurrentSource();
}
}
function normalizeUser(user: Api.SystemManage.UserSimple): AttendeeItem {
return {
userId: user.id,
nickname: user.nickname
};
}
function toggleUser(user: Api.SystemManage.UserSimple) {
if (selectedIdSet.value.has(user.id)) {
model.value = model.value.filter(item => item.userId !== user.id);
return;
}
model.value = [...model.value, normalizeUser(user)];
}
function removeUser(userId: string) {
model.value = model.value.filter(item => item.userId !== userId);
}
function clearSelected() {
model.value = [];
}
function collectDeptIds(node: Api.SystemManage.DeptSimple): string[] {
const ids = [String(node.id)];
if (node.children?.length) {
for (const child of node.children) {
ids.push(...collectDeptIds(child));
}
}
return ids;
}
function findDeptNode(list: Api.SystemManage.DeptSimple[], id: string): Api.SystemManage.DeptSimple | null {
for (const node of list) {
if (String(node.id) === id) {
return node;
}
if (node.children?.length) {
const matched = findDeptNode(node.children, id);
if (matched) {
return matched;
}
}
}
return null;
}
function getDeptUsers(node: Api.SystemManage.DeptSimple) {
const deptIds = new Set(collectDeptIds(node));
return allUsers.value.filter(user => user.deptId && deptIds.has(user.deptId));
}
function getDeptCheckState(node: Api.SystemManage.DeptSimple): TreeCheckState {
const users = getDeptUsers(node);
if (!users.length) {
return 'none';
}
const selectedCountInDept = users.filter(user => selectedIdSet.value.has(user.id)).length;
if (selectedCountInDept === 0) {
return 'none';
}
if (selectedCountInDept === users.length) {
return 'all';
}
return 'partial';
}
function getDeptCheckStateClass(node: Api.SystemManage.DeptSimple) {
const state = getDeptCheckState(node);
return {
'is-checked': state === 'all',
'is-partial': state === 'partial'
};
}
function toggleDeptCheck(node: Api.SystemManage.DeptSimple) {
const users = getDeptUsers(node);
if (!users.length) {
return;
}
const state = getDeptCheckState(node);
const selectedMap = new Map(model.value.map(item => [item.userId, item]));
if (state === 'all') {
users.forEach(user => selectedMap.delete(user.id));
} else {
users.forEach(user => selectedMap.set(user.id, normalizeUser(user)));
}
model.value = Array.from(selectedMap.values());
}
function handleDeptNodeClick(node: Api.SystemManage.DeptSimple) {
currentDeptId.value = String(node.id);
}
function getDeptMetaText(node: Api.SystemManage.DeptSimple) {
const count = getDeptUsers(node).length;
return count > 0 ? `${count}` : '';
}
function matchDeptKeyword(node: Api.SystemManage.DeptSimple, keyword: string): boolean {
if (!keyword) {
return true;
}
if (node.name.toLowerCase().includes(keyword)) {
return true;
}
return Boolean(node.children?.some(child => matchDeptKeyword(child, keyword)));
}
const visibleDeptTree = computed(() => {
const keyword = treeSearch.value.trim().toLowerCase();
if (!keyword) {
return deptTree.value;
}
return deptTree.value.filter(node => matchDeptKeyword(node, keyword));
});
function getSelectedName(item: AttendeeItem) {
return mergedUserMap.value.get(item.userId)?.nickname || item.nickname || item.userId;
}
function getUserDeptText(user: Api.SystemManage.UserSimple) {
return user.deptName || '--';
}
watch(
() => props.teamOptions,
() => {
syncSourceWithAvailableTabs();
const validTeamIds = new Set((props.teamOptions ?? []).map(item => item.userId));
const otherSelected = model.value.filter(item => !validTeamIds.has(item.userId));
const teamSelected = model.value.filter(item => validTeamIds.has(item.userId));
model.value = [...teamSelected, ...otherSelected];
}
);
watch(
() => sourceTabs.value.map(tab => tab.key).join(','),
() => {
syncSourceWithAvailableTabs();
}
);
onMounted(() => {
syncSourceWithAvailableTabs().then(() => preloadCurrentSource());
});
</script>
<template>
<div class="attendee-picker">
<ElPopover
:visible="popoverVisible"
trigger="click"
placement="bottom-start"
:width="720"
popper-class="attendee-picker__popper"
@update:visible="handleVisibleChange"
>
<template #reference>
<div
class="attendee-picker__reference"
:class="{ 'is-focus': popoverVisible }"
tabindex="0"
@keydown.enter.prevent="handleVisibleChange(true)"
@keydown.space.prevent="handleVisibleChange(true)"
>
<div v-if="model.length" class="attendee-picker__reference-chips">
<span v-for="item in referenceSelectedItems" :key="item.userId" class="attendee-picker__reference-chip">
<IconEpUser class="attendee-picker__reference-chip-icon" />
<span class="attendee-picker__reference-chip-name">{{ getSelectedName(item) }}</span>
<button
type="button"
class="attendee-picker__reference-chip-remove"
@click.stop="removeUser(item.userId)"
>
×
</button>
</span>
<span v-if="referenceOverflowCount" class="attendee-picker__reference-count">
+{{ referenceOverflowCount }}
</span>
</div>
<span v-else class="attendee-picker__placeholder">请选择参会人</span>
<span class="attendee-picker__arrow" :class="{ 'is-open': popoverVisible }" />
</div>
</template>
<div class="attendee-picker__dropdown">
<template v-if="sourceTabs.length">
<div class="attendee-picker__tabs">
<button
v-for="tab in sourceTabs"
:key="tab.key"
type="button"
class="attendee-picker__tab"
:class="{ 'is-active': source === tab.key }"
@click="switchSource(tab.key)"
>
{{ tab.label }}
</button>
</div>
<div class="attendee-picker__body" :class="{ 'is-with-tree': source === 'dept' }">
<div v-if="source === 'dept'" class="attendee-picker__panel attendee-picker__panel--tree">
<div class="attendee-picker__panel-head">部门</div>
<div class="attendee-picker__search">
<ElInput v-model="treeSearch" size="small" clearable placeholder="搜索部门..." />
</div>
<div v-loading="deptTreeLoading" class="attendee-picker__panel-body">
<ElTree
:data="visibleDeptTree"
:props="deptTreeProps"
node-key="id"
:expand-on-click-node="false"
:default-expand-all="true"
:indent="14"
class="attendee-picker__tree"
@node-click="handleDeptNodeClick"
>
<template #default="{ data }">
<div class="attendee-picker__node" :class="{ 'is-active': currentDeptId === String(data.id) }">
<span
class="attendee-picker__check"
:class="getDeptCheckStateClass(data)"
@click.stop="toggleDeptCheck(data)"
/>
<IconEpOfficeBuilding class="attendee-picker__node-icon" />
<span class="attendee-picker__node-label">{{ data.name }}</span>
<span v-if="getDeptMetaText(data)" class="attendee-picker__node-meta">
{{ getDeptMetaText(data) }}
</span>
</div>
</template>
</ElTree>
</div>
</div>
<div class="attendee-picker__panel attendee-picker__panel--users">
<div class="attendee-picker__panel-head attendee-picker__panel-head--users">
<span>
候选用户(
<span>{{ filteredUsers.length }}</span>
)
</span>
<ElCheckbox v-model="hideSelected" size="small">隐藏已选</ElCheckbox>
</div>
<div class="attendee-picker__search">
<ElInput v-model="userSearch" size="small" clearable placeholder="搜索用户名 / 部门..." />
</div>
<div v-loading="allUsersLoading" class="attendee-picker__panel-body">
<div v-if="!filteredUsers.length" class="attendee-picker__empty">暂无匹配用户</div>
<div
v-for="user in filteredUsers"
:key="user.id"
class="attendee-picker__user-row"
@click="toggleUser(user)"
>
<span class="attendee-picker__check" :class="{ 'is-checked': selectedIdSet.has(user.id) }" />
<span class="attendee-picker__avatar">{{ user.nickname.slice(0, 1) }}</span>
<div class="attendee-picker__user-main">
<div class="attendee-picker__user-name">{{ user.nickname }}</div>
<div class="attendee-picker__user-meta">{{ getUserDeptText(user) }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="attendee-picker__selected">
<div class="attendee-picker__selected-head">
<span>
已选
<strong>{{ selectedCount }}</strong>
</span>
<button type="button" class="attendee-picker__link" @click="clearSelected">清空</button>
</div>
<div v-if="!model.length" class="attendee-picker__selected-empty">请选择参会人</div>
<div v-else class="attendee-picker__chips">
<span v-for="item in model" :key="item.userId" class="attendee-picker__chip">
<IconEpUser class="attendee-picker__chip-icon" />
<span class="attendee-picker__chip-name">{{ getSelectedName(item) }}</span>
<button type="button" class="attendee-picker__chip-remove" @click="removeUser(item.userId)">×</button>
</span>
</div>
</div>
</template>
<div v-else class="attendee-picker__empty attendee-picker__empty--source">暂无可选人员来源</div>
</div>
</ElPopover>
</div>
</template>
<style scoped>
.attendee-picker {
width: 100%;
}
:global(.attendee-picker__popper.el-popover) {
width: min(720px, calc(100vw - 32px)) !important;
}
.attendee-picker__reference {
display: flex;
align-items: center;
width: 100%;
min-height: 32px;
gap: 8px;
padding: 1px 30px 1px 11px;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-blank);
box-shadow: 0 0 0 0 transparent inset;
color: var(--el-text-color-primary);
cursor: pointer;
font-size: 13px;
line-height: 30px;
outline: none;
position: relative;
transition:
border-color var(--el-transition-duration),
box-shadow var(--el-transition-duration);
}
.attendee-picker__reference:hover,
.attendee-picker__reference.is-focus {
border-color: var(--el-color-primary);
}
.attendee-picker__reference.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
.attendee-picker__reference-chips {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
gap: 6px;
}
.attendee-picker__reference-chip {
display: inline-flex;
align-items: center;
max-width: 124px;
height: 24px;
gap: 4px;
padding: 0 4px 0 7px;
border: 1px solid var(--el-border-color);
border-radius: 999px;
background: var(--el-fill-color-light);
color: var(--el-text-color-primary);
line-height: 22px;
}
.attendee-picker__reference-chip-icon {
flex-shrink: 0;
color: var(--el-color-primary);
font-size: 13px;
}
.attendee-picker__reference-chip-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-picker__reference-chip-remove {
width: 16px;
height: 16px;
flex-shrink: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 12px;
line-height: 16px;
}
.attendee-picker__reference-chip-remove:hover {
background: var(--el-color-danger);
color: #fff;
}
.attendee-picker__reference-count {
flex-shrink: 0;
color: var(--el-color-primary);
font-size: 12px;
}
.attendee-picker__placeholder {
color: var(--el-text-color-placeholder);
}
.attendee-picker__arrow {
position: absolute;
top: 50%;
right: 11px;
width: 7px;
height: 7px;
border-right: 1px solid var(--el-text-color-placeholder);
border-bottom: 1px solid var(--el-text-color-placeholder);
transform: translateY(-65%) rotate(45deg);
transition: transform var(--el-transition-duration);
}
.attendee-picker__arrow.is-open {
transform: translateY(-35%) rotate(225deg);
}
.attendee-picker__dropdown {
display: flex;
flex-direction: column;
gap: 10px;
}
.attendee-picker__tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--el-border-color);
}
.attendee-picker__tab {
margin-bottom: -1px;
padding: 6px 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
font-size: 12.5px;
}
.attendee-picker__tab.is-active {
border-bottom-color: var(--el-color-primary);
color: var(--el-color-primary);
font-weight: 600;
}
.attendee-picker__body {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
min-height: 240px;
height: min(280px, 40vh);
}
.attendee-picker__body.is-with-tree {
grid-template-columns: 240px 1fr;
}
.attendee-picker__panel {
display: flex;
min-width: 0;
overflow: hidden;
flex-direction: column;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: #fff;
}
.attendee-picker__panel-head {
padding: 6px 10px;
border-bottom: 1px solid var(--el-border-color);
background: #fafbfc;
color: var(--el-text-color-regular);
font-size: 12px;
}
.attendee-picker__panel-head--users {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.attendee-picker__search {
padding: 6px 10px;
border-bottom: 1px solid var(--el-border-color);
}
.attendee-picker__panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.attendee-picker__tree {
padding: 4px;
background: transparent;
}
.attendee-picker__tree :deep(.el-tree-node__content) {
height: 32px;
padding-right: 8px !important;
border-radius: 4px;
}
.attendee-picker__tree :deep(.el-tree-node__content:hover) {
background: var(--el-fill-color-light);
}
.attendee-picker__tree :deep(.el-tree-node__expand-icon) {
padding: 4px;
color: var(--el-text-color-placeholder);
font-size: 14px;
}
.attendee-picker__node {
display: flex;
align-items: center;
min-width: 0;
height: 100%;
flex: 1;
gap: 8px;
color: var(--el-text-color-primary);
font-size: 13px;
}
.attendee-picker__node.is-active {
color: var(--el-color-primary);
font-weight: 500;
}
.attendee-picker__check {
position: relative;
width: 14px;
height: 14px;
flex-shrink: 0;
border: 1px solid var(--el-border-color);
border-radius: 2px;
background: var(--el-bg-color);
cursor: pointer;
}
.attendee-picker__check:hover {
border-color: var(--el-color-primary);
}
.attendee-picker__check.is-checked,
.attendee-picker__check.is-partial {
border-color: var(--el-color-primary);
background: var(--el-color-primary);
}
.attendee-picker__check.is-checked::after {
position: absolute;
top: 1px;
left: 4px;
width: 3px;
height: 7px;
border: solid #fff;
border-width: 0 1px 1px 0;
content: '';
transform: rotate(45deg);
}
.attendee-picker__check.is-partial::after {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 2px;
margin: -1px 0 0 -4px;
border-radius: 1px;
background: #fff;
content: '';
}
.attendee-picker__node-icon {
flex-shrink: 0;
color: var(--el-text-color-secondary);
font-size: 15px;
}
.attendee-picker__node-label {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-picker__node-meta {
flex-shrink: 0;
padding-left: 6px;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.attendee-picker__user-row {
display: flex;
align-items: center;
height: 42px;
gap: 10px;
padding: 0 10px;
border-bottom: 1px solid var(--el-border-color-lighter);
cursor: pointer;
}
.attendee-picker__user-row:hover {
background: var(--el-fill-color);
}
.attendee-picker__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex-shrink: 0;
border-radius: 50%;
background: linear-gradient(135deg, #c7d2fe, #93c5fd);
color: #fff;
font-size: 12px;
font-weight: 600;
}
.attendee-picker__user-main {
min-width: 0;
flex: 1;
overflow: hidden;
}
.attendee-picker__user-name,
.attendee-picker__user-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-picker__user-name {
color: var(--el-text-color-primary);
font-size: 13px;
}
.attendee-picker__user-meta {
margin-top: 2px;
color: var(--el-text-color-placeholder);
font-size: 11.5px;
}
.attendee-picker__empty {
padding: 40px 0;
color: var(--el-text-color-placeholder);
font-size: 12px;
text-align: center;
}
.attendee-picker__selected {
padding: 8px 12px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: #f8fafc;
}
.attendee-picker__selected-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
color: var(--el-text-color-regular);
font-size: 11.5px;
}
.attendee-picker__selected-head strong {
color: var(--el-color-primary);
font-size: 12.5px;
}
.attendee-picker__selected-empty {
display: flex;
align-items: center;
min-height: 26px;
color: var(--el-text-color-placeholder);
font-size: 11.5px;
}
.attendee-picker__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.attendee-picker__chip {
display: inline-flex;
align-items: center;
max-width: 160px;
gap: 4px;
padding: 2px 4px 2px 8px;
border: 1px solid var(--el-border-color-darker);
border-radius: 999px;
background: #fff;
font-size: 11.5px;
}
.attendee-picker__chip-icon {
flex-shrink: 0;
color: var(--el-color-primary);
font-size: 13px;
}
.attendee-picker__chip-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-picker__chip-remove {
width: 16px;
height: 16px;
flex-shrink: 0;
border: none;
border-radius: 50%;
background: var(--el-fill-color);
color: var(--el-text-color-regular);
cursor: pointer;
font-size: 11px;
}
.attendee-picker__chip-remove:hover {
background: var(--el-color-danger);
color: #fff;
}
.attendee-picker__link {
padding: 0;
border: none;
background: transparent;
color: var(--el-color-danger);
cursor: pointer;
font-size: 11.5px;
}
.attendee-picker__link:hover {
text-decoration: underline;
}
@media (width <= 1024px) {
.attendee-picker__body,
.attendee-picker__body.is-with-tree {
grid-template-columns: 1fr;
height: auto;
}
.attendee-picker__panel-body {
max-height: 240px;
}
}
</style>