feat(动态切换对象域下的对象):对象域下的对象可以动态切换。 fix(产品需求、项目需求): 按照会议意见修改诸多细节。 fix(产品对象域的概览界面): 把假数据换成真实的需求统计数据。
1019 lines
25 KiB
Vue
1019 lines
25 KiB
Vue
<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>
|