serv\web- 多租户修改

This commit is contained in:
2025-10-29 19:08:22 +08:00
parent c5c134fbb3
commit 82b6f14e64
86 changed files with 4446 additions and 2730 deletions

View File

@@ -0,0 +1,862 @@
<template>
<div v-if="visible" class="modal-overlay" @click.self="handleCancel">
<div class="modal-content large">
<div class="modal-header">
<h3 class="modal-title">{{ title }}</h3>
<button class="modal-close" @click="handleCancel"></button>
</div>
<div class="modal-body">
<div class="generic-selector">
<!-- 左侧可选项 -->
<div class="selector-panel">
<div class="panel-header">
<h4 class="panel-title">{{ leftTitle }}</h4>
<span class="panel-count">
{{ countText(availableList.length) }}
</span>
</div>
<div class="panel-search" v-if="showSearch">
<input
v-model="searchAvailable"
type="text"
:placeholder="searchPlaceholder"
class="search-input-small"
/>
</div>
<div class="panel-body left-panel">
<!-- 列表模式 -->
<div v-if="!useTree" class="item-list">
<div
v-for="item in filteredAvailable"
:key="getItemId(item)"
class="list-item"
:class="{ selected: getItemId(item) && selectedAvailable.includes(getItemId(item)) }"
@click="getItemId(item) && toggleAvailable(getItemId(item))"
@dblclick="getItemId(item) && moveToTarget(getItemId(item))"
>
<input
type="checkbox"
:checked="!!(getItemId(item) && selectedAvailable.includes(getItemId(item)))"
@click.stop="getItemId(item) && toggleAvailable(getItemId(item))"
/>
<span class="item-label">{{ getItemLabel(item) }}</span>
<span class="item-sublabel" v-if="getItemSublabel(item)">{{ getItemSublabel(item) }}</span>
</div>
<!-- 加载更多提示 -->
<div v-if="usePagination && loadingMore" class="loading-more">
<div class="loading-spinner-small"></div>
<span>加载中...</span>
</div>
<div v-if="usePagination && !hasMore && availableList.length > 0" class="no-more">
已加载全部数据
</div>
</div>
<!-- 树形模式 -->
<div v-else class="tree-list">
<TreeNode
v-for="node in treeData"
:key="getNodeId(node)"
:node="node"
:tree-props="treeProps"
:expanded-keys="expandedKeys"
:selected-ids="selectedAvailable"
:only-leaf-selectable="onlyLeafSelectable"
@toggle-expand="toggleExpand"
@toggle-select="toggleAvailable"
@dblclick="moveToTarget"
/>
</div>
</div>
</div>
<!-- 中间操作按钮 -->
<div class="selector-actions">
<button
class="action-btn"
@click="moveSelectedToTarget"
:disabled="selectedAvailable.length === 0"
:title="mode === 'add' ? '添加选中' : '删除选中'"
>
<span class="arrow"></span>
<span class="btn-text">{{ mode === 'add' ? '添加' : '删除' }}</span>
</button>
<button
class="action-btn"
@click="moveAllToTarget"
:disabled="availableList.length === 0"
:title="mode === 'add' ? '全部添加' : '全部删除'"
>
<span class="arrow"></span>
<span class="btn-text">全部</span>
</button>
<button
class="action-btn"
@click="moveBackSelected"
:disabled="selectedTarget.length === 0"
title="移回选中"
>
<span class="arrow"></span>
<span class="btn-text">移回</span>
</button>
<button
class="action-btn"
@click="moveBackAll"
:disabled="targetList.length === 0"
title="全部移回"
>
<span class="arrow"></span>
<span class="btn-text">全部</span>
</button>
</div>
<!-- 右侧已选项 -->
<div class="selector-panel">
<div class="panel-header">
<h4 class="panel-title">{{ rightTitle }}</h4>
<span class="panel-count">{{ countText(targetList.length) }}</span>
</div>
<div class="panel-search" v-if="showSearch">
<input
v-model="searchTarget"
type="text"
:placeholder="searchPlaceholder"
class="search-input-small"
/>
</div>
<div class="panel-body">
<div class="item-list">
<div
v-for="item in filteredTarget"
:key="getItemId(item)"
class="list-item"
:class="{ selected: getItemId(item) && selectedTarget.includes(getItemId(item)) }"
@click="getItemId(item) && toggleTarget(getItemId(item))"
@dblclick="getItemId(item) && moveBackToAvailable(getItemId(item))"
>
<input
type="checkbox"
:checked="!!(getItemId(item) && selectedTarget.includes(getItemId(item)))"
@click.stop="getItemId(item) && toggleTarget(getItemId(item))"
/>
<span class="item-label">{{ getItemLabel(item) }}</span>
<span class="item-sublabel" v-if="getItemSublabel(item)">{{ getItemSublabel(item) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-primary" @click="handleConfirm" :disabled="loading">
{{ loading ? '处理中...' : '确定' }}
</button>
<button class="btn-default" @click="handleCancel">取消</button>
</div>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
import { ref, computed, watch, nextTick } from 'vue';
import type { ResultDomain, PageParam } from '@/types';
import { TreeNode } from '@/components/base';
// 泛型项类型
type GenericItem = Record<string, any>;
interface ItemConfig {
/** ID 字段名 */
id: string;
/** 显示标签字段名 */
label: string;
/** 副标签字段名(可选) */
sublabel?: string;
}
interface Props {
visible?: boolean;
mode?: 'add' | 'remove';
title?: string;
leftTitle?: string;
rightTitle?: string;
/** 【新】获取所有可选项的接口 */
fetchAvailableApi?: () => Promise<ResultDomain<GenericItem>>;
/** 【新】获取已选项的接口 */
fetchSelectedApi?: () => Promise<ResultDomain<GenericItem>>;
/** 【新】过滤已选项的方法(返回过滤后的可选项) */
filterSelected?: (available: GenericItem[], selected: GenericItem[]) => GenericItem[];
/** 可选项列表兼容旧方式如果提供了fetchAvailableApi则忽略 */
availableItems?: GenericItem[];
/** 初始已选项列表兼容旧方式如果提供了fetchSelectedApi则忽略 */
initialTargetItems?: GenericItem[];
loading?: boolean;
/** 字段配置 */
itemConfig: ItemConfig;
/** 单位名称(用于计数显示) */
unitName?: string;
/** 搜索占位符 */
searchPlaceholder?: string;
/** 是否显示搜索框 */
showSearch?: boolean;
/** 分页加载API方法 */
fetchApi?: (pageParam: PageParam, filter?: any) => Promise<ResultDomain<GenericItem>>;
/** 过滤参数 */
filterParams?: any;
/** 每页数量 */
pageSize?: number;
/** 是否使用分页加载 */
usePagination?: boolean;
/** 是否使用树形展示 */
useTree?: boolean;
/** 树形数据转换函数 */
treeTransform?: (data: GenericItem[]) => any[];
/** 树节点的配置 */
treeProps?: {
children?: string;
label?: string;
id?: string;
};
/** 是否只允许选择叶子节点(树形模式下有效) */
onlyLeafSelectable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
mode: 'add',
title: '选择',
leftTitle: '可选项',
rightTitle: '已选项',
availableItems: () => [],
initialTargetItems: () => [],
loading: false,
unitName: '项',
searchPlaceholder: '搜索...',
showSearch: true,
pageSize: 20,
usePagination: false,
useTree: false,
treeProps: () => ({
children: 'children',
label: 'label',
id: 'id'
}),
onlyLeafSelectable: false
});
const emit = defineEmits<{
'update:visible': [value: boolean];
confirm: [items: GenericItem[]];
cancel: [];
}>();
// 数据列表
const availableList = ref<GenericItem[]>([]);
const targetList = ref<GenericItem[]>([]);
// 树形数据相关
const treeData = ref<any[]>([]);
const expandedKeys = ref<Set<string>>(new Set());
// 选中状态
const selectedAvailable = ref<string[]>([]);
const selectedTarget = ref<string[]>([]);
// 搜索关键词
const searchAvailable = ref('');
const searchTarget = ref('');
// 分页相关
const currentPage = ref(1);
const totalElements = ref(0);
const hasMore = ref(true);
const loadingMore = ref(false);
const availablePanelRef = ref<HTMLElement | null>(null);
// 获取项的 ID
function getItemId(item: GenericItem): string {
return String(item[props.itemConfig.id] || '');
}
// 获取项的标签
function getItemLabel(item: GenericItem): string {
return String(item[props.itemConfig.label] || '');
}
// 获取项的副标签
function getItemSublabel(item: GenericItem): string {
if (!props.itemConfig.sublabel) return '';
return String(item[props.itemConfig.sublabel] || '');
}
// 获取树节点 ID
function getNodeId(node: any): string {
const idProp = props.treeProps?.id || 'id';
return String(node[idProp] || '');
}
// 获取树节点标签
// function getNodeLabel(node: any): string {
// const labelProp = props.treeProps?.label || 'label';
// return String(node[labelProp] || '');
// }
// 获取树节点子节点
// function getNodeChildren(node: any): any[] {
// const childrenProp = props.treeProps?.children || 'children';
// return node[childrenProp] || [];
// }
// 计数文本
function countText(count: number): string {
if (props.usePagination && totalElements.value > 0) {
return `${count}/${totalElements.value} ${props.unitName}`;
}
return `${count} ${props.unitName}`;
}
// 监听props变化初始化数据
watch(() => props.visible, (newVal) => {
if (newVal) {
initializeData();
} else {
resetData();
}
}, { immediate: true });
// 监听搜索关键词变化
watch(searchAvailable, () => {
if (props.usePagination && props.fetchApi) {
resetPaginationAndLoad();
}
});
// 初始化数据
async function initializeData() {
try {
// 优先使用接口方式加载数据
if (props.fetchAvailableApi || props.fetchSelectedApi) {
await loadDataFromApis();
} else if (props.usePagination && props.fetchApi) {
// 使用分页方式
currentPage.value = 1;
hasMore.value = true;
availableList.value = [];
await loadAvailableItems();
targetList.value = [...props.initialTargetItems];
} else {
// 使用传入的数据
availableList.value = [...props.availableItems];
targetList.value = [...props.initialTargetItems];
}
// 如果使用树形展示,转换数据
if (props.useTree && props.treeTransform) {
treeData.value = props.treeTransform(availableList.value);
expandAllNodes(treeData.value);
}
selectedAvailable.value = [];
selectedTarget.value = [];
searchAvailable.value = '';
searchTarget.value = '';
await nextTick();
bindScrollEvent();
} catch (error) {
console.error('初始化数据失败:', error);
}
}
// 从接口加载数据
async function loadDataFromApis() {
try {
// 加载所有可选项
if (props.fetchAvailableApi) {
const availableResult = await props.fetchAvailableApi();
if (availableResult.success) {
const allAvailable = availableResult.dataList || [];
// 加载已选项
if (props.fetchSelectedApi) {
const selectedResult = await props.fetchSelectedApi();
if (selectedResult.success) {
targetList.value = selectedResult.dataList || [];
// 过滤已选项
if (props.filterSelected) {
availableList.value = props.filterSelected(allAvailable, targetList.value);
} else {
// 默认过滤逻辑根据ID过滤
const selectedIds = new Set(targetList.value.map(item => getItemId(item)));
availableList.value = allAvailable.filter(item => !selectedIds.has(getItemId(item)));
}
}
} else {
// 如果没有提供已选接口,使用传入的初始数据
targetList.value = [...props.initialTargetItems];
const selectedIds = new Set(targetList.value.map(item => getItemId(item)));
availableList.value = allAvailable.filter(item => !selectedIds.has(getItemId(item)));
}
}
} else if (props.fetchSelectedApi) {
// 只有已选接口
const selectedResult = await props.fetchSelectedApi();
if (selectedResult.success) {
targetList.value = selectedResult.dataList || [];
}
availableList.value = [...props.availableItems];
const selectedIds = new Set(targetList.value.map(item => getItemId(item)));
availableList.value = availableList.value.filter(item => !selectedIds.has(getItemId(item)));
}
} catch (error) {
console.error('从接口加载数据失败:', error);
throw error;
}
}
// 展开所有树节点
function expandAllNodes(nodes: any[]) {
const childrenProp = props.treeProps?.children || 'children';
nodes.forEach(node => {
const nodeId = getNodeId(node);
if (nodeId) {
expandedKeys.value.add(nodeId);
}
const children = node[childrenProp] || [];
if (children && children.length > 0) {
expandAllNodes(children);
}
});
}
// 重置数据
function resetData() {
availableList.value = [];
targetList.value = [];
treeData.value = [];
expandedKeys.value.clear();
selectedAvailable.value = [];
selectedTarget.value = [];
searchAvailable.value = '';
searchTarget.value = '';
currentPage.value = 1;
totalElements.value = 0;
hasMore.value = true;
loadingMore.value = false;
unbindScrollEvent();
}
// 加载可选项
async function loadAvailableItems() {
if (!props.fetchApi) return;
try {
loadingMore.value = true;
const pageParam: PageParam = {
pageNumber: currentPage.value,
pageSize: props.pageSize
};
const filter = searchAvailable.value
? { ...props.filterParams, keyword: searchAvailable.value }
: props.filterParams;
const res = await props.fetchApi(pageParam, filter);
if (res.success) {
const newItems = res.dataList || [];
const targetItemIds = targetList.value.map(item => getItemId(item));
const filteredItems = newItems.filter(item => !targetItemIds.includes(getItemId(item)));
if (currentPage.value === 1) {
availableList.value = filteredItems;
} else {
availableList.value.push(...filteredItems);
}
totalElements.value = res.pageParam?.totalElements || 0;
const totalPages = Math.ceil(totalElements.value / props.pageSize);
hasMore.value = currentPage.value < totalPages;
}
} catch (error) {
console.error('加载数据失败:', error);
} finally {
loadingMore.value = false;
}
}
// 重置分页并重新加载
async function resetPaginationAndLoad() {
currentPage.value = 1;
hasMore.value = true;
availableList.value = [];
await loadAvailableItems();
}
// 绑定滚动事件
function bindScrollEvent() {
const panelBody = document.querySelector('.panel-body.left-panel');
if (panelBody) {
availablePanelRef.value = panelBody as HTMLElement;
panelBody.addEventListener('scroll', handleScroll);
}
}
// 解绑滚动事件
function unbindScrollEvent() {
if (availablePanelRef.value) {
availablePanelRef.value.removeEventListener('scroll', handleScroll);
availablePanelRef.value = null;
}
}
// 处理滚动事件
function handleScroll(event: Event) {
if (!props.usePagination || !props.fetchApi) return;
const target = event.target as HTMLElement;
const scrollTop = target.scrollTop;
const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight;
if (scrollHeight - scrollTop - clientHeight < 50 && hasMore.value && !loadingMore.value) {
currentPage.value++;
loadAvailableItems();
}
}
// 过滤可选项
const filteredAvailable = computed(() => {
if (props.usePagination) {
return availableList.value;
}
if (!searchAvailable.value) {
return availableList.value;
}
const keyword = searchAvailable.value.toLowerCase();
return availableList.value.filter(item => {
const label = getItemLabel(item).toLowerCase();
const sublabel = getItemSublabel(item).toLowerCase();
return label.includes(keyword) || sublabel.includes(keyword);
});
});
// 过滤已选项
const filteredTarget = computed(() => {
if (!searchTarget.value) {
return targetList.value;
}
const keyword = searchTarget.value.toLowerCase();
return targetList.value.filter(item => {
const label = getItemLabel(item).toLowerCase();
const sublabel = getItemSublabel(item).toLowerCase();
return label.includes(keyword) || sublabel.includes(keyword);
});
});
// 切换树节点展开/折叠
function toggleExpand(nodeId: string) {
if (expandedKeys.value.has(nodeId)) {
expandedKeys.value.delete(nodeId);
} else {
expandedKeys.value.add(nodeId);
}
}
// 检查节点是否展开暂未使用保留供TreeNode组件使用
// function isExpanded(nodeId: string): boolean {
// return expandedKeys.value.has(nodeId);
// }
// 获取树节点的所有子节点ID递归
function getAllChildrenIds(node: any): string[] {
const childrenProp = props.treeProps?.children || 'children';
const children = node[childrenProp] || [];
const ids: string[] = [];
children.forEach((child: any) => {
const childId = getNodeId(child);
if (childId) {
ids.push(childId);
// 递归获取子节点的子节点
ids.push(...getAllChildrenIds(child));
}
});
return ids;
}
// 在树形数据中查找节点
function findNodeInTree(nodeId: string, nodes: any[]): any | null {
for (const node of nodes) {
const id = getNodeId(node);
if (id === nodeId) {
return node;
}
const childrenProp = props.treeProps?.children || 'children';
const children = node[childrenProp] || [];
if (children.length > 0) {
const found = findNodeInTree(nodeId, children);
if (found) return found;
}
}
return null;
}
// 检查节点是否为叶子节点
function isLeafNode(node: any): boolean {
const childrenProp = props.treeProps?.children || 'children';
const children = node[childrenProp] || [];
return children.length === 0;
}
// 切换可选项的选中状态(支持级联选择)
function toggleAvailable(itemId: string) {
const index = selectedAvailable.value.indexOf(itemId);
if (props.useTree && treeData.value.length > 0) {
// 树形模式下的级联选择
const node = findNodeInTree(itemId, treeData.value);
if (node) {
// 如果只允许选择叶子节点,检查是否为叶子节点
if (props.onlyLeafSelectable && !isLeafNode(node)) {
// 非叶子节点,不允许选择
return;
}
const childrenIds = getAllChildrenIds(node);
if (index > -1) {
// 取消选中:移除当前节点和所有子节点
selectedAvailable.value = selectedAvailable.value.filter(
id => id !== itemId && !childrenIds.includes(id)
);
} else {
// 选中:添加当前节点和所有子节点
selectedAvailable.value.push(itemId);
childrenIds.forEach(childId => {
if (!selectedAvailable.value.includes(childId)) {
selectedAvailable.value.push(childId);
}
});
}
} else {
// 节点未找到,按普通方式处理
if (index > -1) {
selectedAvailable.value.splice(index, 1);
} else {
selectedAvailable.value.push(itemId);
}
}
} else {
// 非树形模式,普通切换
if (index > -1) {
selectedAvailable.value.splice(index, 1);
} else {
selectedAvailable.value.push(itemId);
}
}
}
// 切换已选项的选中状态(暂不支持级联,因为已选项通常不需要级联)
function toggleTarget(itemId: string) {
const index = selectedTarget.value.indexOf(itemId);
if (index > -1) {
selectedTarget.value.splice(index, 1);
} else {
selectedTarget.value.push(itemId);
}
}
// 从树形数据中收集所有节点(扁平化)
function flattenTree(nodes: any[]): any[] {
const result: any[] = [];
const childrenProp = props.treeProps?.children || 'children';
nodes.forEach(node => {
result.push(node);
const children = node[childrenProp] || [];
if (children.length > 0) {
result.push(...flattenTree(children));
}
});
return result;
}
// 移动选中项到目标区域
function moveSelectedToTarget() {
let itemsToMove: any[] = [];
if (props.useTree && treeData.value.length > 0) {
// 树形模式:从扁平化的树数据中查找
const flatItems = flattenTree(treeData.value);
itemsToMove = flatItems.filter(item =>
selectedAvailable.value.includes(getNodeId(item))
);
} else {
// 列表模式:从可选列表中查找
itemsToMove = availableList.value.filter(item =>
selectedAvailable.value.includes(getItemId(item))
);
}
targetList.value.push(...itemsToMove);
if (props.useTree && treeData.value.length > 0) {
// 树形模式:需要重新构建树(移除已选项)
availableList.value = availableList.value.filter(item =>
!selectedAvailable.value.includes(getItemId(item))
);
// 重新转换为树形结构
if (props.treeTransform) {
treeData.value = props.treeTransform(availableList.value);
}
} else {
// 列表模式:直接过滤
availableList.value = availableList.value.filter(item =>
!selectedAvailable.value.includes(getItemId(item))
);
}
selectedAvailable.value = [];
}
// 移动单个项到目标区域(双击)
function moveToTarget(itemId: string) {
let item: any = null;
let itemsToMove: any[] = [];
if (props.useTree && treeData.value.length > 0) {
// 树形模式:查找节点和所有子节点
const node = findNodeInTree(itemId, treeData.value);
if (node) {
// 如果只允许选择叶子节点,检查是否为叶子节点
if (props.onlyLeafSelectable && !isLeafNode(node)) {
// 非叶子节点,不允许移动
return;
}
item = node;
const childrenIds = getAllChildrenIds(node);
const flatItems = flattenTree(treeData.value);
// 收集当前节点和所有子节点
itemsToMove = flatItems.filter(i => {
const id = getNodeId(i);
return id === itemId || childrenIds.includes(id);
});
// 移动到已选列表
targetList.value.push(...itemsToMove);
// 从可选列表中移除
const idsToRemove = new Set([itemId, ...childrenIds]);
availableList.value = availableList.value.filter(i =>
!idsToRemove.has(getItemId(i))
);
// 重新构建树
if (props.treeTransform) {
treeData.value = props.treeTransform(availableList.value);
}
}
} else {
// 列表模式
item = availableList.value.find(i => getItemId(i) === itemId);
if (item) {
targetList.value.push(item);
availableList.value = availableList.value.filter(i => getItemId(i) !== itemId);
}
}
}
// 移动所有项到目标区域
function moveAllToTarget() {
if (props.useTree && treeData.value.length > 0) {
// 树形模式:扁平化所有节点
const flatItems = flattenTree(treeData.value);
targetList.value.push(...flatItems);
availableList.value = [];
treeData.value = [];
} else {
// 列表模式
targetList.value.push(...availableList.value);
availableList.value = [];
}
selectedAvailable.value = [];
}
// 移回选中项到可选区域
function moveBackSelected() {
const itemsToMoveBack = targetList.value.filter(item =>
selectedTarget.value.includes(getItemId(item))
);
availableList.value.push(...itemsToMoveBack);
targetList.value = targetList.value.filter(item =>
!selectedTarget.value.includes(getItemId(item))
);
// 如果是树形模式,重新构建树
if (props.useTree && props.treeTransform) {
treeData.value = props.treeTransform(availableList.value);
expandAllNodes(treeData.value);
}
selectedTarget.value = [];
}
// 移回单个项到可选区域(双击)
function moveBackToAvailable(itemId: string) {
const item = targetList.value.find(i => getItemId(i) === itemId);
if (item) {
availableList.value.push(item);
targetList.value = targetList.value.filter(i => getItemId(i) !== itemId);
// 如果是树形模式,重新构建树
if (props.useTree && props.treeTransform) {
treeData.value = props.treeTransform(availableList.value);
expandAllNodes(treeData.value);
}
}
}
// 移回所有项到可选区域
function moveBackAll() {
availableList.value.push(...targetList.value);
targetList.value = [];
// 如果是树形模式,重新构建树
if (props.useTree && props.treeTransform) {
treeData.value = props.treeTransform(availableList.value);
expandAllNodes(treeData.value);
}
selectedTarget.value = [];
}
// 确认
function handleConfirm() {
emit('confirm', targetList.value);
emit('update:visible', false);
}
// 取消
function handleCancel() {
emit('cancel');
emit('update:visible', false);
}
</script>
<style lang="scss" scoped>
@import './selector-styles.scss';
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="tree-node">
<div
class="tree-node-content"
:style="{ paddingLeft: `${level * 20}px` }"
:class="{
selected: selectedIds.includes(nodeId),
disabled: isCheckboxDisabled
}"
@click.stop="handleClick"
@dblclick.stop="handleDblClick"
>
<!-- 展开/折叠图标 -->
<span
v-if="hasChildren"
class="expand-icon"
:class="{ expanded }"
@click.stop="$emit('toggle-expand', nodeId)"
>
<img src="@/assets/imgs/arrow-down.svg" :class="{expanded}"/>
</span>
<span v-else class="expand-icon-placeholder"></span>
<!-- 复选框 -->
<input
type="checkbox"
:checked="selectedIds.includes(nodeId)"
:disabled="isCheckboxDisabled"
@click.stop="handleCheckboxClick"
/>
<!-- 节点标签 -->
<span class="node-label">{{ nodeLabel }}</span>
</div>
<!-- 子节点 -->
<div v-if="hasChildren && expanded" class="tree-node-children">
<TreeNode
v-for="child in children"
:key="getChildId(child)"
:node="child"
:level="level + 1"
:selected-ids="selectedIds"
:tree-props="treeProps"
:expanded-keys="expandedKeys"
:only-leaf-selectable="onlyLeafSelectable"
@toggle-expand="$emit('toggle-expand', $event)"
@toggle-select="$emit('toggle-select', $event)"
@dblclick="$emit('dblclick', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({
name: 'TreeNode'
});
interface Props {
node: any;
level?: number;
selectedIds: string[];
treeProps?: {
children?: string;
label?: string;
id?: string;
};
expandedKeys: Set<string>;
onlyLeafSelectable?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
level: 0,
treeProps: () => ({
children: 'children',
label: 'label',
id: 'id'
}),
onlyLeafSelectable: false
});
const emit = defineEmits<{
'toggle-expand': [nodeId: string];
'toggle-select': [nodeId: string];
'dblclick': [nodeId: string];
}>();
const nodeId = computed(() => {
const idProp = props.treeProps?.id || 'id';
return String(props.node[idProp] || '');
});
const nodeLabel = computed(() => {
const labelProp = props.treeProps?.label || 'label';
return String(props.node[labelProp] || '');
});
const children = computed(() => {
const childrenProp = props.treeProps?.children || 'children';
return props.node[childrenProp] || [];
});
const hasChildren = computed(() => children.value && children.value.length > 0);
const expanded = computed(() => props.expandedKeys.has(nodeId.value));
// 判断复选框是否应该被禁用
const isCheckboxDisabled = computed(() => {
// 如果只允许选择叶子节点,且当前节点有子节点,则禁用复选框
return props.onlyLeafSelectable && hasChildren.value;
});
function getChildId(child: any): string {
const idProp = props.treeProps?.id || 'id';
return String(child[idProp] || '');
}
function handleClick() {
// 点击节点主体时切换选中状态(如果复选框未禁用)
if (!isCheckboxDisabled.value) {
emit('toggle-select', nodeId.value);
}
}
function handleCheckboxClick() {
// 复选框点击事件(如果未禁用)
if (!isCheckboxDisabled.value) {
emit('toggle-select', nodeId.value);
}
}
function handleDblClick() {
// 双击时触发(如果复选框未禁用)
if (!isCheckboxDisabled.value) {
emit('dblclick', nodeId.value);
}
}
</script>
<style lang="scss" scoped>
.tree-node {
user-select: none;
&-content {
display: flex;
align-items: center;
padding: 6px 8px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: #f5f7fa;
}
&.selected {
background-color: #e6f7ff;
}
.expand-icon {
display: inline-block;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
font-size: 10px;
cursor: pointer;
transition: transform 0.2s;
color: #606266;
img {
transform: rotate(-90deg);
&.expanded {
transform: rotate(0deg);
}
}
&:hover {
color: #409EFF;
}
}
.expand-icon-placeholder {
display: inline-block;
width: 16px;
}
input[type="checkbox"] {
margin: 0 8px;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}
.node-label {
flex: 1;
font-size: 14px;
color: #606266;
}
&.disabled {
cursor: not-allowed;
.node-label {
color: #999;
}
}
}
&-children {
margin-left: 0;
}
}
</style>

View File

@@ -7,4 +7,6 @@ export { default as MenuSidebar } from './MenuSidebar.vue';
export { default as TopNavigation } from './TopNavigation.vue';
export { default as UserDropdown } from './UserDropdown.vue';
export { default as Search } from './Search.vue';
export { default as CenterHead } from './CenterHead.vue';
export { default as CenterHead } from './CenterHead.vue';
export { default as GenericSelector } from './GenericSelector.vue';
export { default as TreeNode } from './TreeNode.vue';

View File

@@ -0,0 +1,301 @@
// 通用选择器样式
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 3000;
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
max-height: 90vh;
&.large {
width: 900px;
}
}
.modal-header {
padding: 16px 24px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
.modal-title {
margin: 0;
font-size: 18px;
font-weight: 500;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
color: #999;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
line-height: 24px;
&:hover {
color: #666;
}
}
}
.modal-body {
flex: 1;
overflow: hidden;
padding: 16px 24px;
}
.modal-footer {
padding: 12px 24px;
border-top: 1px solid #e8e8e8;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.generic-selector {
display: flex;
gap: 16px;
height: 500px;
}
.selector-panel {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid #e8e8e8;
border-radius: 4px;
background: #fafafa;
.panel-header {
padding: 12px 16px;
background: #f0f0f0;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
.panel-title {
margin: 0;
font-size: 14px;
font-weight: 500;
color: #333;
}
.panel-count {
font-size: 12px;
color: #999;
}
}
.panel-search {
padding: 12px 16px;
border-bottom: 1px solid #e8e8e8;
background: white;
.search-input-small {
width: 100%;
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
outline: none;
&:focus {
border-color: #409EFF;
}
}
}
.panel-body {
flex: 1;
overflow-y: auto;
background: white;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
&:hover {
background: #a8a8a8;
}
}
}
}
.item-list,
.tree-list {
padding: 8px;
}
.tree-notice {
padding: 40px 20px;
text-align: center;
color: #909399;
font-size: 14px;
}
.list-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: #f5f7fa;
}
&.selected {
background-color: #e6f7ff;
}
input[type="checkbox"] {
margin-right: 8px;
cursor: pointer;
}
.item-label {
flex: 1;
font-size: 14px;
color: #333;
}
.item-sublabel {
font-size: 12px;
color: #999;
margin-left: 8px;
}
}
.selector-actions {
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
.action-btn {
padding: 8px 16px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
transition: all 0.2s;
min-width: 60px;
&:hover:not(:disabled) {
border-color: #409EFF;
color: #409EFF;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.arrow {
font-size: 16px;
font-weight: bold;
}
.btn-text {
font-size: 12px;
}
}
}
.loading-more,
.no-more {
text-align: center;
padding: 12px;
color: #999;
font-size: 12px;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.loading-spinner-small {
width: 16px;
height: 16px;
border: 2px solid #f3f3f3;
border-top: 2px solid #409EFF;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.btn-primary {
padding: 8px 20px;
background-color: #409EFF;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
&:hover:not(:disabled) {
background-color: #66b1ff;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.btn-default {
padding: 8px 20px;
background-color: white;
color: #606266;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
&:hover {
border-color: #409EFF;
color: #409EFF;
}
}

View File

@@ -8,5 +8,4 @@ export * from './text';
export * from './file';
// 导出 user 用户组件
export * from './user';

View File

@@ -1,213 +0,0 @@
# UserSelect 用户选择组件
## 功能特性
- ✅ 支持双栏选择器布局
- ✅ 支持搜索功能(左右两侧独立搜索)
- ✅ 三种操作方式:双击、勾选+按钮、全部按钮
-**支持滚动分页加载**(性能优化)
- ✅ 支持传入静态数据或API方法
- ✅ 完全封装的样式和逻辑
## Props 配置
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| visible | Boolean | false | 控制弹窗显示/隐藏 |
| mode | 'add' \| 'remove' | 'add' | 选择模式 |
| title | String | '人员选择' | 弹窗标题 |
| leftTitle | String | '可选人员' | 左侧面板标题 |
| rightTitle | String | '已选人员' | 右侧面板标题 |
| availableUsers | UserVO[] | [] | 左区域静态数据 |
| initialTargetUsers | UserVO[] | [] | 初始已选人员 |
| loading | Boolean | false | 确认按钮加载状态 |
| **usePagination** | Boolean | false | **是否启用分页加载** |
| **fetchApi** | Function | undefined | **分页加载API方法** |
| **filterParams** | Object | {} | **API过滤参数** |
| **pageSize** | Number | 20 | **每页数量** |
## Events 事件
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:visible | (value: boolean) | 更新弹窗显示状态 |
| confirm | (users: UserVO[]) | 确认提交,返回选中的用户列表 |
| cancel | - | 取消操作 |
## 使用方式
### 方式一:传入静态数据(适合数据量少的场景)
```vue
<template>
<UserSelect
v-model:visible="showSelector"
mode="add"
title="选择用户"
:available-users="allUsers"
:initial-target-users="[]"
@confirm="handleConfirm"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { UserSelect } from '@/components';
import type { UserVO } from '@/types';
const showSelector = ref(false);
const allUsers = ref<UserVO[]>([]);
function handleConfirm(selectedUsers: UserVO[]) {
console.log('选中的用户:', selectedUsers);
showSelector.value = false;
}
</script>
```
### 方式二:使用分页加载(推荐,适合数据量大的场景)
```vue
<template>
<UserSelect
v-model:visible="showSelector"
mode="add"
title="选择用户"
:use-pagination="true"
:fetch-api="userApi.getUserPage"
:filter-params="filterParams"
:page-size="20"
:loading="saving"
@confirm="handleConfirm"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { UserSelect } from '@/components';
import { userApi } from '@/apis/system';
import type { UserVO } from '@/types';
const showSelector = ref(false);
const saving = ref(false);
const filterParams = ref({
// 可以添加额外的过滤条件
status: 0
});
async function handleConfirm(selectedUsers: UserVO[]) {
saving.value = true;
try {
// 处理业务逻辑
for (const user of selectedUsers) {
await someApi.addUser(user.id);
}
showSelector.value = false;
} finally {
saving.value = false;
}
}
</script>
```
### 方式三:混合模式(添加模式用分页,删除模式用静态)
```vue
<template>
<UserSelect
v-model:visible="showSelector"
:mode="selectorMode"
:title="selectorMode === 'add' ? '添加人员' : '删除人员'"
:available-users="selectorMode === 'remove' ? currentUsers : []"
:use-pagination="selectorMode === 'add'"
:fetch-api="selectorMode === 'add' ? userApi.getUserPage : undefined"
:filter-params="filterParams"
@confirm="handleConfirm"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { UserSelect } from '@/components';
import { userApi } from '@/apis/system';
import type { UserVO } from '@/types';
const showSelector = ref(false);
const selectorMode = ref<'add' | 'remove'>('add');
const currentUsers = ref<UserVO[]>([]);
const filterParams = ref({});
function handleConfirm(selectedUsers: UserVO[]) {
if (selectorMode.value === 'add') {
// 添加用户逻辑
} else {
// 删除用户逻辑
}
}
</script>
```
## API 方法要求
使用 `usePagination` 时,`fetchApi` 方法需要符合以下签名:
```typescript
async function fetchApi(
pageParam: PageParam,
filter?: any
): Promise<ResultDomain<UserVO>>
```
### PageParam 类型
```typescript
interface PageParam {
page: number; // 当前页码从1开始
size: number; // 每页数量
}
```
### ResultDomain 类型
```typescript
interface ResultDomain<T> {
success: boolean;
message?: string;
dataList?: T[];
pageParam?: {
totalElements: number; // 总记录数
// ... 其他分页信息
};
}
```
## 特性说明
### 滚动分页加载
- 当启用 `usePagination` 时,左侧面板支持滚动加载更多数据
- 滚动到距离底部 50px 时自动加载下一页
- 显示加载状态和"已加载全部数据"提示
- 搜索时自动重置分页并重新加载
### 搜索功能
- **分页模式**:搜索关键词会传递给 API在服务端进行过滤
- **静态模式**:搜索在前端进行过滤
### 数据过滤
组件会自动过滤掉右侧已选择的用户,避免重复选择。
## 性能优化建议
1. **数据量 < 100**:使用静态数据模式
2. **数据量 > 100**:使用分页加载模式
3. **数据量 > 1000**:使用分页加载 + 服务端搜索
## 注意事项
1. 使用分页模式时,`availableUsers` 会被忽略
2. 删除模式下通常使用静态数据(当前已分配的用户)
3. `filterParams` 支持传入额外的过滤条件,会合并到 API 请求中

View File

@@ -1,781 +0,0 @@
<template>
<div v-if="visible" class="modal-overlay" @click.self="handleCancel">
<div class="modal-content large">
<div class="modal-header">
<h3 class="modal-title">{{ title }}</h3>
<button class="modal-close" @click="handleCancel"></button>
</div>
<div class="modal-body">
<div class="user-selector">
<!-- 左侧可选人员 -->
<div class="selector-panel">
<div class="panel-header">
<h4 class="panel-title">{{ leftTitle }}</h4>
<span class="panel-count">
{{ usePagination && totalElements > 0 ? `${availableList.length}/${totalElements}` : `${availableList.length}` }}
</span>
</div>
<div class="panel-search">
<input
v-model="searchAvailable"
type="text"
placeholder="搜索人员..."
class="search-input-small"
/>
</div>
<div class="panel-body left-panel">
<div class="user-list">
<div
v-for="user in filteredAvailable"
:key="user.id"
class="user-item"
:class="{ selected: user.id && selectedAvailable.includes(user.id) }"
@click="user.id && toggleAvailable(user.id)"
@dblclick="user.id && moveToTarget(user.id)"
>
<input
type="checkbox"
:checked="!!(user.id && selectedAvailable.includes(user.id))"
@click.stop="user.id && toggleAvailable(user.id)"
/>
<span class="user-name">{{ user.username }}</span>
<span class="user-dept" v-if="user.deptName">({{ user.deptName }})</span>
</div>
<!-- 加载更多提示 -->
<div v-if="usePagination && loadingMore" class="loading-more">
<div class="loading-spinner-small"></div>
<span>加载中...</span>
</div>
<div v-if="usePagination && !hasMore && availableList.length > 0" class="no-more">
已加载全部数据
</div>
</div>
</div>
</div>
<!-- 中间操作按钮 -->
<div class="selector-actions">
<button
class="action-btn"
@click="moveSelectedToTarget"
:disabled="selectedAvailable.length === 0"
:title="mode === 'add' ? '添加选中' : '删除选中'"
>
<span class="arrow"></span>
<span class="btn-text">{{ mode === 'add' ? '添加' : '删除' }}</span>
</button>
<button
class="action-btn"
@click="moveAllToTarget"
:disabled="availableList.length === 0"
:title="mode === 'add' ? '全部添加' : '全部删除'"
>
<span class="arrow"></span>
<span class="btn-text">全部</span>
</button>
<button
class="action-btn"
@click="moveBackSelected"
:disabled="selectedTarget.length === 0"
title="移回选中"
>
<span class="arrow"></span>
<span class="btn-text">移回</span>
</button>
<button
class="action-btn"
@click="moveBackAll"
:disabled="targetList.length === 0"
title="全部移回"
>
<span class="arrow"></span>
<span class="btn-text">全部</span>
</button>
</div>
<!-- 右侧目标人员 -->
<div class="selector-panel">
<div class="panel-header">
<h4 class="panel-title">{{ rightTitle }}</h4>
<span class="panel-count">{{ targetList.length }} </span>
</div>
<div class="panel-search">
<input
v-model="searchTarget"
type="text"
placeholder="搜索人员..."
class="search-input-small"
/>
</div>
<div class="panel-body">
<div class="user-list">
<div
v-for="user in filteredTarget"
:key="user.id"
class="user-item"
:class="{ selected: user.id && selectedTarget.includes(user.id) }"
@click="user.id && toggleTarget(user.id)"
@dblclick="user.id && moveBackToAvailable(user.id)"
>
<input
type="checkbox"
:checked="!!(user.id && selectedTarget.includes(user.id))"
@click.stop="user.id && toggleTarget(user.id)"
/>
<span class="user-name">{{ user.username }}</span>
<span class="user-dept" v-if="user.deptName">({{ user.deptName }})</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-primary" @click="handleConfirm" :disabled="loading">
{{ loading ? '处理中...' : '确定' }}
</button>
<button class="btn-default" @click="handleCancel">取消</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import type { UserVO, ResultDomain, PageParam } from '@/types';
interface Props {
visible?: boolean;
mode?: 'add' | 'remove';
title?: string;
leftTitle?: string;
rightTitle?: string;
availableUsers?: UserVO[];
initialTargetUsers?: UserVO[];
loading?: boolean;
// 分页加载API方法
fetchApi?: (pageParam: PageParam, filter?: any) => Promise<ResultDomain<UserVO>>;
// 过滤参数
filterParams?: any;
// 每页数量
pageSize?: number;
// 是否使用分页加载
usePagination?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
mode: 'add',
title: '人员选择',
leftTitle: '可选人员',
rightTitle: '已选人员',
availableUsers: () => [],
initialTargetUsers: () => [],
loading: false,
pageSize: 20,
usePagination: false
});
const emit = defineEmits<{
'update:visible': [value: boolean];
confirm: [users: UserVO[]];
cancel: [];
}>();
// 数据列表
const availableList = ref<UserVO[]>([]);
const targetList = ref<UserVO[]>([]);
// 选中状态
const selectedAvailable = ref<string[]>([]);
const selectedTarget = ref<string[]>([]);
// 搜索关键词
const searchAvailable = ref('');
const searchTarget = ref('');
// 分页相关
const currentPage = ref(1);
const totalElements = ref(0);
const hasMore = ref(true);
const loadingMore = ref(false);
const availablePanelRef = ref<HTMLElement | null>(null);
// 监听props变化初始化数据
watch(() => props.visible, (newVal) => {
if (newVal) {
initializeData();
} else {
resetData();
}
}, { immediate: true });
// 监听搜索关键词变化
watch(searchAvailable, () => {
if (props.usePagination && props.fetchApi) {
resetPaginationAndLoad();
}
});
// 初始化数据
async function initializeData() {
if (props.usePagination && props.fetchApi) {
// 使用分页加载
currentPage.value = 1;
hasMore.value = true;
availableList.value = [];
await loadAvailableUsers();
} else {
// 使用传入的数据
availableList.value = [...props.availableUsers];
}
targetList.value = [...props.initialTargetUsers];
selectedAvailable.value = [];
selectedTarget.value = [];
searchAvailable.value = '';
searchTarget.value = '';
// 绑定滚动事件
await nextTick();
bindScrollEvent();
}
// 重置数据
function resetData() {
availableList.value = [];
targetList.value = [];
selectedAvailable.value = [];
selectedTarget.value = [];
searchAvailable.value = '';
searchTarget.value = '';
currentPage.value = 1;
totalElements.value = 0;
hasMore.value = true;
loadingMore.value = false;
// 解绑滚动事件
unbindScrollEvent();
}
// 加载可选用户数据
async function loadAvailableUsers() {
if (!props.fetchApi || loadingMore.value || !hasMore.value) return;
loadingMore.value = true;
try {
const pageParam: PageParam = {
page: currentPage.value,
size: props.pageSize
};
// 构建过滤参数
const filter = {
...props.filterParams,
...(searchAvailable.value ? { username: searchAvailable.value } : {})
};
const res = await props.fetchApi(pageParam, filter);
if (res.success) {
const newUsers = res.dataList || [];
// 过滤掉已在右侧的用户
const targetUserIds = targetList.value.map(u => u.id);
const filteredUsers = newUsers.filter(u => !targetUserIds.includes(u.id));
if (currentPage.value === 1) {
availableList.value = filteredUsers;
} else {
availableList.value.push(...filteredUsers);
}
totalElements.value = res.pageParam?.totalElements || 0;
const totalPages = Math.ceil(totalElements.value / props.pageSize);
hasMore.value = currentPage.value < totalPages;
}
} catch (error) {
console.error('加载用户数据失败:', error);
} finally {
loadingMore.value = false;
}
}
// 重置分页并重新加载
async function resetPaginationAndLoad() {
currentPage.value = 1;
hasMore.value = true;
availableList.value = [];
await loadAvailableUsers();
}
// 绑定滚动事件
function bindScrollEvent() {
const panelBody = document.querySelector('.panel-body.left-panel');
if (panelBody) {
availablePanelRef.value = panelBody as HTMLElement;
panelBody.addEventListener('scroll', handleScroll);
}
}
// 解绑滚动事件
function unbindScrollEvent() {
if (availablePanelRef.value) {
availablePanelRef.value.removeEventListener('scroll', handleScroll);
availablePanelRef.value = null;
}
}
// 处理滚动事件
function handleScroll(event: Event) {
if (!props.usePagination || !props.fetchApi) return;
const target = event.target as HTMLElement;
const scrollTop = target.scrollTop;
const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight;
// 距离底部50px时加载更多
if (scrollHeight - scrollTop - clientHeight < 50 && hasMore.value && !loadingMore.value) {
currentPage.value++;
loadAvailableUsers();
}
}
// 过滤可选人员
const filteredAvailable = computed(() => {
// 如果使用分页加载搜索在API层面处理不需要前端过滤
if (props.usePagination) {
return availableList.value;
}
// 前端过滤
if (!searchAvailable.value) {
return availableList.value;
}
const keyword = searchAvailable.value.toLowerCase();
return availableList.value.filter(user =>
user.username?.toLowerCase().includes(keyword) ||
user.deptName?.toLowerCase().includes(keyword)
);
});
// 过滤目标人员
const filteredTarget = computed(() => {
if (!searchTarget.value) {
return targetList.value;
}
const keyword = searchTarget.value.toLowerCase();
return targetList.value.filter(user =>
user.username?.toLowerCase().includes(keyword) ||
user.deptName?.toLowerCase().includes(keyword)
);
});
// 切换可选用户的选中状态
function toggleAvailable(userId: string) {
const index = selectedAvailable.value.indexOf(userId);
if (index > -1) {
selectedAvailable.value.splice(index, 1);
} else {
selectedAvailable.value.push(userId);
}
}
// 切换目标用户的选中状态
function toggleTarget(userId: string) {
const index = selectedTarget.value.indexOf(userId);
if (index > -1) {
selectedTarget.value.splice(index, 1);
} else {
selectedTarget.value.push(userId);
}
}
// 移动选中用户到目标区域
function moveSelectedToTarget() {
const usersToMove = availableList.value.filter(user =>
selectedAvailable.value.includes(user.id!)
);
targetList.value.push(...usersToMove);
availableList.value = availableList.value.filter(user =>
!selectedAvailable.value.includes(user.id!)
);
selectedAvailable.value = [];
}
// 移动单个用户到目标区域(双击)
function moveToTarget(userId: string) {
const user = availableList.value.find(u => u.id === userId);
if (user) {
targetList.value.push(user);
availableList.value = availableList.value.filter(u => u.id !== userId);
}
}
// 移动所有用户到目标区域
function moveAllToTarget() {
targetList.value.push(...availableList.value);
availableList.value = [];
selectedAvailable.value = [];
}
// 移回选中用户到可选区域
function moveBackSelected() {
const usersToMoveBack = targetList.value.filter(user =>
selectedTarget.value.includes(user.id!)
);
availableList.value.push(...usersToMoveBack);
targetList.value = targetList.value.filter(user =>
!selectedTarget.value.includes(user.id!)
);
selectedTarget.value = [];
}
// 移回单个用户到可选区域(双击)
function moveBackToAvailable(userId: string) {
const user = targetList.value.find(u => u.id === userId);
if (user) {
availableList.value.push(user);
targetList.value = targetList.value.filter(u => u.id !== userId);
}
}
// 移回所有用户到可选区域
function moveBackAll() {
availableList.value.push(...targetList.value);
targetList.value = [];
selectedTarget.value = [];
}
// 确认
function handleConfirm() {
emit('confirm', targetList.value);
}
// 取消
function handleCancel() {
emit('update:visible', false);
emit('cancel');
}
</script>
<style lang="scss" scoped>
// 弹窗遮罩
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-content {
background: #fff;
border-radius: 8px;
width: 90%;
max-width: 900px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #f0f0f0;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0;
}
.modal-close {
width: 32px;
height: 32px;
border: none;
background: #f5f7fa;
border-radius: 50%;
cursor: pointer;
font-size: 18px;
color: #909399;
transition: all 0.3s;
&:hover {
background: #ecf5ff;
color: #409eff;
}
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
gap: 12px;
}
// 人员选择器样式
.user-selector {
display: flex;
gap: 20px;
height: 500px;
}
.selector-panel {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid #e0e0e0;
border-radius: 6px;
overflow: hidden;
background: #fafafa;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f5f7fa;
border-bottom: 1px solid #e0e0e0;
}
.panel-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin: 0;
}
.panel-count {
font-size: 13px;
color: #909399;
background: #fff;
padding: 2px 10px;
border-radius: 10px;
}
.panel-search {
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
background: #fff;
}
.search-input-small {
width: 100%;
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.3s;
&:focus {
outline: none;
border-color: #409eff;
}
&::placeholder {
color: #c0c4cc;
}
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 8px;
background: #fff;
}
.user-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
&:hover {
background: #f5f7fa;
}
&.selected {
background: #ecf5ff;
border: 1px solid #b3d8ff;
}
input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
}
.user-name {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.user-dept {
font-size: 12px;
color: #909399;
margin-left: auto;
}
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
color: #909399;
font-size: 13px;
}
.loading-spinner-small {
width: 16px;
height: 16px;
border: 2px solid #f0f0f0;
border-top-color: #409eff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.no-more {
text-align: center;
padding: 12px;
color: #c0c4cc;
font-size: 12px;
}
.selector-actions {
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
padding: 0 10px;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 16px;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
min-width: 80px;
&:hover:not(:disabled) {
background: #409eff;
border-color: #409eff;
color: #fff;
.arrow {
color: #fff;
}
.btn-text {
color: #fff;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f5f7fa;
}
.arrow {
font-size: 20px;
font-weight: bold;
color: #409eff;
transition: color 0.3s;
}
.btn-text {
font-size: 13px;
color: #606266;
transition: color 0.3s;
}
}
.btn-primary,
.btn-default {
padding: 10px 24px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
border: none;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn-primary {
background: #409eff;
color: #fff;
&:hover:not(:disabled) {
background: #66b1ff;
}
}
.btn-default {
background: #fff;
color: #606266;
border: 1px solid #dcdfe6;
&:hover {
color: #409eff;
border-color: #409eff;
}
}
</style>

View File

@@ -1,8 +0,0 @@
/**
* @description 用户相关组件
* @author yslg
* @since 2025-10-22
*/
export { default as UserSelect } from './UserSelect.vue';