serv\web- 多租户修改
This commit is contained in:
@@ -83,11 +83,11 @@ export const learningTaskApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 发布学习任务
|
||||
* 发布\下架学习任务
|
||||
* @param taskID 任务ID
|
||||
* @returns Promise<ResultDomain<LearningTask>>
|
||||
*/
|
||||
async publishTask(task: LearningTask): Promise<ResultDomain<LearningTask>> {
|
||||
async changeTaskStatus(task: LearningTask): Promise<ResultDomain<LearningTask>> {
|
||||
const response = await api.put<LearningTask>(`${this.learningTaskPrefix}/status`, task);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -96,13 +96,13 @@ export const deptApi = {
|
||||
|
||||
/**
|
||||
* @description 查询部门角色列表
|
||||
* @param dept 部门角色信息
|
||||
* @param deptRole 部门角色信息
|
||||
* @returns Promise<ResultDomain<SysDeptRole>> 部门角色列表
|
||||
* @author yslg
|
||||
* @ since 2025-10-06
|
||||
*/
|
||||
async getDeptRoleList(dept: SysDeptRole): Promise<ResultDomain<SysDeptRole>> {
|
||||
const response = await api.post<SysDeptRole>('/depts/role/list', dept);
|
||||
async getDeptRoleList(deptRole: SysDeptRole): Promise<ResultDomain<SysDeptRole>> {
|
||||
const response = await api.post<SysDeptRole>('/depts/role/list', deptRole);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -97,8 +97,8 @@ export const userApi = {
|
||||
* @param user 用户信息
|
||||
* @returns Promise<ResultDomain<SysUserDeptRole>>
|
||||
*/
|
||||
async getUserDeptRole(user: SysUserDeptRole): Promise<ResultDomain<SysUserDeptRole>> {
|
||||
const response = await api.post<SysUserDeptRole>('/users/bind/deptrole/list', user);
|
||||
async getUserDeptRole(user: UserDeptRoleVO): Promise<ResultDomain<UserDeptRoleVO>> {
|
||||
const response = await api.post<UserDeptRoleVO>('/users/bind/deptrole/list', user);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
862
schoolNewsWeb/src/components/base/GenericSelector.vue
Normal file
862
schoolNewsWeb/src/components/base/GenericSelector.vue
Normal 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>
|
||||
|
||||
221
schoolNewsWeb/src/components/base/TreeNode.vue
Normal file
221
schoolNewsWeb/src/components/base/TreeNode.vue
Normal 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>
|
||||
|
||||
@@ -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';
|
||||
301
schoolNewsWeb/src/components/base/selector-styles.scss
Normal file
301
schoolNewsWeb/src/components/base/selector-styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,4 @@ export * from './text';
|
||||
export * from './file';
|
||||
|
||||
// 导出 user 用户组件
|
||||
export * from './user';
|
||||
|
||||
|
||||
@@ -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 请求中
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @description 用户相关组件
|
||||
* @author yslg
|
||||
* @since 2025-10-22
|
||||
*/
|
||||
|
||||
export { default as UserSelect } from './UserSelect.vue';
|
||||
|
||||
@@ -51,6 +51,6 @@ export const APP_CONFIG = {
|
||||
refreshThreshold: 5 * 60 * 1000 // 提前5分钟刷新
|
||||
}
|
||||
};
|
||||
export const PUBLIC_IMG_PATH = '/schoolNewsWeb/img';
|
||||
export const PUBLIC_IMG_PATH = 'http://localhost:8080/schoolNewsWeb/img';
|
||||
export default APP_CONFIG;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { BaseDTO } from '../base';
|
||||
import { SysRole } from '../role';
|
||||
import { SysUserDeptRole } from '../user';
|
||||
|
||||
/**
|
||||
* 系统部门
|
||||
@@ -15,6 +16,8 @@ export interface SysDept extends BaseDTO {
|
||||
deptID?: string;
|
||||
/** 父部门ID */
|
||||
parentID?: string;
|
||||
/** 部门路径,格式:/root_department/dept_001/ */
|
||||
deptPath?: string;
|
||||
/** 部门名称 */
|
||||
name?: string;
|
||||
/** 部门描述 */
|
||||
@@ -30,13 +33,35 @@ export interface SysDept extends BaseDTO {
|
||||
/**
|
||||
* 部门角色VO
|
||||
*/
|
||||
export interface DeptRoleVO {
|
||||
/** 部门信息 */
|
||||
dept?: SysDept;
|
||||
/** 角色信息 */
|
||||
role?: SysRole;
|
||||
export interface UserDeptRoleVO {
|
||||
|
||||
depts?: SysDept[];
|
||||
roles?: SysRole[];
|
||||
userDeptRoles?: SysUserDeptRole[];
|
||||
|
||||
// 扁平化字段,用于权限查询优化
|
||||
/** 用户ID */
|
||||
userID?: string;
|
||||
/** 部门ID */
|
||||
deptID?: string;
|
||||
/** 部门名称 */
|
||||
deptName?: string;
|
||||
/** 部门描述 */
|
||||
deptDescription?: string;
|
||||
/** 父部门ID */
|
||||
parentID?: string;
|
||||
/** 父部门名称 */
|
||||
parentName?: string;
|
||||
/** 父部门描述 */
|
||||
parentDescription?: string;
|
||||
/** 角色ID */
|
||||
roleID?: string;
|
||||
/** 角色名称 */
|
||||
roleName?: string;
|
||||
/** 角色描述 */
|
||||
roleDescription?: string;
|
||||
/** 部门路径,用于快速权限继承判断 */
|
||||
deptPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,5 +73,19 @@ export interface SysDeptRole extends BaseDTO {
|
||||
deptID?: string;
|
||||
/** 角色ID */
|
||||
roleID?: string;
|
||||
|
||||
// 扁平化字段(用于前端展示,从关联查询中获取)
|
||||
/** 部门名称 */
|
||||
deptName?: string;
|
||||
/** 部门描述 */
|
||||
deptDescription?: string;
|
||||
/** 父部门ID */
|
||||
parentID?: string;
|
||||
/** 部门路径 */
|
||||
deptPath?: string;
|
||||
/** 角色名称 */
|
||||
roleName?: string;
|
||||
/** 角色描述 */
|
||||
roleDescription?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,17 +93,4 @@ export interface SysUserDeptRole extends BaseDTO {
|
||||
deptID?: string;
|
||||
/** 角色ID */
|
||||
roleID?: string;
|
||||
}
|
||||
|
||||
export interface UserDeptRoleVO extends BaseDTO {
|
||||
/** 单个用户信息 */
|
||||
user?: SysUser;
|
||||
/** 用户列表 */
|
||||
users?: SysUser[];
|
||||
/** 部门列表 */
|
||||
depts?: SysDept[];
|
||||
/** 角色列表 */
|
||||
roles?: SysRole[];
|
||||
/** 用户部门角色关联列表 */
|
||||
userDeptRoles?: SysUserDeptRole[];
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
<el-tabs v-model="activeTab">
|
||||
|
||||
<el-tab-pane label="学习记录" name="task-records">
|
||||
<StudyRecords />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
@@ -17,7 +16,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ElTabs, ElTabPane } from 'element-plus';
|
||||
import StudyRecords from './components/StudyRecords.vue';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'StudyManagementView'
|
||||
|
||||
@@ -112,60 +112,19 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定角色对话框 -->
|
||||
<el-dialog v-model="bindRoleDialogVisible" title="绑定角色" width="800px" @close="resetBindList">
|
||||
<div class="role-binding-container">
|
||||
<!-- 部门信息显示 -->
|
||||
<div class="dept-info" v-if="currentDept">
|
||||
<h4>部门信息:{{ currentDept.name }}</h4>
|
||||
<p>部门ID:{{ currentDept.deptID }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 角色绑定状态表格 -->
|
||||
<el-table :data="roleList" style="width: 100%" border stripe>
|
||||
<el-table-column width="80" label="绑定状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="isRoleSelected(row.roleID) ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ isRoleSelected(row.roleID) ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="角色名称" min-width="150" />
|
||||
<el-table-column prop="roleID" label="角色ID" min-width="120" />
|
||||
<el-table-column prop="description" label="角色描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
:type="isRoleSelected(row.roleID) ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
@click="toggleRoleSelection(row)"
|
||||
>
|
||||
{{ isRoleSelected(row.roleID) ? '解绑' : '绑定' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="binding-stats">
|
||||
<el-alert
|
||||
:title="`已绑定 ${selectedRoles.length} 个角色,未绑定 ${roleList.length - selectedRoles.length} 个角色`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="bindRoleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRoleBinding" :loading="submitting">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<GenericSelector
|
||||
v-model:visible="bindRoleDialogVisible"
|
||||
:title="`绑定角色 - ${currentDept?.name || ''}`"
|
||||
left-title="可选角色"
|
||||
right-title="已选角色"
|
||||
:fetch-available-api="fetchAllRoles"
|
||||
:fetch-selected-api="fetchDeptRoles"
|
||||
:item-config="{ id: 'roleID', label: 'name', sublabel: 'description' }"
|
||||
unit-name="个"
|
||||
search-placeholder="搜索角色名称或描述..."
|
||||
@confirm="handleRoleConfirm"
|
||||
@cancel="resetBindList"
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -175,6 +134,7 @@ import { deptApi } from '@/apis/system/dept';
|
||||
import { roleApi } from '@/apis/system/role';
|
||||
import { SysDept, SysRole } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
import { GenericSelector } from '@/components/base';
|
||||
|
||||
defineOptions({
|
||||
name: 'DeptManageView'
|
||||
@@ -190,12 +150,7 @@ const submitting = ref(false);
|
||||
const treeRef = ref();
|
||||
|
||||
// 角色绑定相关数据
|
||||
const roleList = ref<SysRole[]>([]);
|
||||
const selectedRoles = ref<string[]>([]);
|
||||
const currentDept = ref<SysDept | null>(null);
|
||||
const bindList = ref<{ roles: SysRole[] }>({
|
||||
roles: []
|
||||
});
|
||||
|
||||
// 对话框状态
|
||||
const dialogVisible = ref(false);
|
||||
@@ -452,59 +407,39 @@ function resetForm() {
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有可选角色的接口
|
||||
async function fetchAllRoles() {
|
||||
return await roleApi.getAllRoles();
|
||||
}
|
||||
|
||||
// 获取部门已绑定角色的接口
|
||||
async function fetchDeptRoles() {
|
||||
if (!currentDept.value) {
|
||||
return {
|
||||
success: true,
|
||||
dataList: [],
|
||||
code: 200,
|
||||
message: '',
|
||||
login: true,
|
||||
auth: true
|
||||
};
|
||||
}
|
||||
return await deptApi.getDeptByRole(currentDept.value);
|
||||
}
|
||||
|
||||
// 查看绑定角色
|
||||
async function handleBindRole(row: SysDept) {
|
||||
currentDept.value = row;
|
||||
|
||||
try {
|
||||
// 获取所有角色
|
||||
const roleResult = await roleApi.getAllRoles();
|
||||
roleList.value = roleResult.dataList || [];
|
||||
|
||||
// 获取已绑定的角色
|
||||
const bindingResult = await deptApi.getDeptByRole(row);
|
||||
bindList.value.roles = bindingResult.dataList || [];
|
||||
|
||||
// 设置已选中的角色
|
||||
selectedRoles.value = bindList.value.roles.map(role => role.roleID).filter((id): id is string => !!id);
|
||||
|
||||
console.log('已绑定的角色:', bindList.value.roles);
|
||||
console.log('所有角色:', roleList.value);
|
||||
bindRoleDialogVisible.value = true;
|
||||
} catch (error) {
|
||||
console.error('获取角色绑定信息失败:', error);
|
||||
ElMessage.error('获取角色绑定信息失败');
|
||||
}
|
||||
bindRoleDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 重置绑定列表
|
||||
function resetBindList() {
|
||||
bindList.value = {
|
||||
roles: []
|
||||
};
|
||||
selectedRoles.value = [];
|
||||
currentDept.value = null;
|
||||
}
|
||||
|
||||
// 检查角色是否已选中
|
||||
function isRoleSelected(roleID: string | undefined): boolean {
|
||||
return roleID ? selectedRoles.value.includes(roleID) : false;
|
||||
}
|
||||
|
||||
// 切换角色选择状态
|
||||
function toggleRoleSelection(role: SysRole) {
|
||||
if (!role.roleID) return;
|
||||
|
||||
const index = selectedRoles.value.indexOf(role.roleID);
|
||||
if (index > -1) {
|
||||
selectedRoles.value.splice(index, 1);
|
||||
} else {
|
||||
selectedRoles.value.push(role.roleID);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存角色绑定
|
||||
async function saveRoleBinding() {
|
||||
// 角色选择确认 - 在confirm时提交请求
|
||||
async function handleRoleConfirm(items: SysRole[]) {
|
||||
if (!currentDept.value || !currentDept.value.deptID) {
|
||||
ElMessage.error('部门信息不完整');
|
||||
return;
|
||||
@@ -513,21 +448,22 @@ async function saveRoleBinding() {
|
||||
try {
|
||||
submitting.value = true;
|
||||
|
||||
// 获取当前已绑定的角色ID
|
||||
const currentBoundRoles = (bindList.value.roles || []).map(role => role.roleID).filter((id): id is string => !!id);
|
||||
// 获取当前已绑定的角色
|
||||
const currentBoundResult = await deptApi.getDeptByRole(currentDept.value);
|
||||
const currentBoundIds = (currentBoundResult.dataList || []).map(r => r.roleID).filter((id): id is string => !!id);
|
||||
|
||||
// 新选择的角色ID
|
||||
const newSelectedIds = items.map(r => r.roleID).filter((id): id is string => !!id);
|
||||
|
||||
// 找出需要绑定的角色(新增的)
|
||||
const rolesToBind = selectedRoles.value.filter(roleID => !currentBoundRoles.includes(roleID));
|
||||
const rolesToBind = newSelectedIds.filter(id => !currentBoundIds.includes(id));
|
||||
|
||||
// 找出需要解绑的角色(移除的)
|
||||
const rolesToUnbind = currentBoundRoles.filter(roleID => !selectedRoles.value.includes(roleID));
|
||||
const rolesToUnbind = currentBoundIds.filter(id => !newSelectedIds.includes(id));
|
||||
|
||||
// 构建需要绑定的角色对象数组
|
||||
if (rolesToBind.length > 0) {
|
||||
const rolesToBindObjects = rolesToBind.map(roleID => {
|
||||
const role = roleList.value.find(r => r.roleID === roleID);
|
||||
return role || { roleID: roleID };
|
||||
});
|
||||
const rolesToBindObjects = items.filter(r => r.roleID && rolesToBind.includes(r.roleID));
|
||||
|
||||
const bindDept = {
|
||||
dept: currentDept.value,
|
||||
@@ -539,10 +475,7 @@ async function saveRoleBinding() {
|
||||
|
||||
// 构建需要解绑的角色对象数组
|
||||
if (rolesToUnbind.length > 0) {
|
||||
const rolesToUnbindObjects = rolesToUnbind.map(roleID => {
|
||||
const role = roleList.value.find(r => r.roleID === roleID);
|
||||
return role || { roleID: roleID };
|
||||
});
|
||||
const rolesToUnbindObjects = (currentBoundResult.dataList || []).filter(r => r.roleID && rolesToUnbind.includes(r.roleID));
|
||||
|
||||
const unbindDept = {
|
||||
dept: currentDept.value,
|
||||
@@ -553,7 +486,6 @@ async function saveRoleBinding() {
|
||||
}
|
||||
|
||||
ElMessage.success('角色绑定保存成功');
|
||||
bindRoleDialogVisible.value = false;
|
||||
|
||||
// 刷新部门列表
|
||||
await loadDeptList();
|
||||
@@ -799,30 +731,4 @@ async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 角色绑定容器样式
|
||||
.role-binding-container {
|
||||
.dept-info {
|
||||
background: #f5f7fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.binding-stats {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -30,9 +30,12 @@
|
||||
<template #default="{ data }">
|
||||
<div class="custom-tree-node">
|
||||
<div class="node-label">
|
||||
<el-icon v-if="data.icon" class="node-icon">
|
||||
<component :is="data.icon" />
|
||||
</el-icon>
|
||||
<img
|
||||
v-if="data.icon"
|
||||
:src="PUBLIC_IMG_PATH + '/' + data.icon"
|
||||
class="node-icon"
|
||||
:alt="data.name"
|
||||
/>
|
||||
<span class="node-name">{{ data.name }}</span>
|
||||
<el-tag
|
||||
:type="getMenuTypeTagType(data.type)"
|
||||
@@ -156,61 +159,19 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定权限对话框 -->
|
||||
<el-dialog v-model="bindPermissionDialogVisible" title="绑定权限" width="800px" @close="resetBindList">
|
||||
<div class="permission-binding-container">
|
||||
<!-- 菜单信息显示 -->
|
||||
<div class="menu-info" v-if="currentMenu">
|
||||
<h4>菜单信息:{{ currentMenu.name }}</h4>
|
||||
<p>菜单ID:{{ currentMenu.menuID }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 权限绑定状态表格 -->
|
||||
<el-table :data="permissionList" style="width: 100%" border stripe>
|
||||
<el-table-column width="80" label="绑定状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="isPermissionSelected(row.permissionID) ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ isPermissionSelected(row.permissionID) ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="权限名称" min-width="150" />
|
||||
<el-table-column prop="permissionID" label="权限ID" min-width="120" />
|
||||
<el-table-column prop="code" label="权限编码" min-width="150" />
|
||||
<el-table-column prop="description" label="权限描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
:type="isPermissionSelected(row.permissionID) ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
@click="togglePermissionSelection(row)"
|
||||
>
|
||||
{{ isPermissionSelected(row.permissionID) ? '解绑' : '绑定' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="binding-stats">
|
||||
<el-alert
|
||||
:title="`已绑定 ${selectedPermissions.length} 个权限,未绑定 ${permissionList.length - selectedPermissions.length} 个权限`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="bindPermissionDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="savePermissionBinding" :loading="submitting">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<GenericSelector
|
||||
v-model:visible="bindPermissionDialogVisible"
|
||||
:title="`绑定权限 - ${currentMenu?.name || ''}`"
|
||||
left-title="可选权限"
|
||||
right-title="已选权限"
|
||||
:fetch-available-api="fetchAllPermissions"
|
||||
:fetch-selected-api="fetchMenuPermissions"
|
||||
:item-config="{ id: 'permissionID', label: 'name', sublabel: 'code' }"
|
||||
unit-name="个"
|
||||
search-placeholder="搜索权限名称或编码..."
|
||||
@confirm="handlePermissionConfirm"
|
||||
@cancel="resetBindList"
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -220,6 +181,8 @@ import { menuApi } from '@/apis/system/menu';
|
||||
import { permissionApi } from '@/apis/system/permission';
|
||||
import { SysMenu, SysPermission } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
import { PUBLIC_IMG_PATH } from '@/config';
|
||||
import { GenericSelector } from '@/components/base';
|
||||
|
||||
defineOptions({
|
||||
name: 'MenuManageView'
|
||||
@@ -235,12 +198,7 @@ const submitting = ref(false);
|
||||
const treeRef = ref();
|
||||
|
||||
// 权限绑定相关数据
|
||||
const permissionList = ref<SysPermission[]>([]);
|
||||
const selectedPermissions = ref<string[]>([]);
|
||||
const currentMenu = ref<SysMenu | null>(null);
|
||||
const bindList = ref<{ permissions: SysPermission[] }>({
|
||||
permissions: []
|
||||
});
|
||||
|
||||
// 对话框状态
|
||||
const dialogVisible = ref(false);
|
||||
@@ -533,65 +491,38 @@ function resetForm() {
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有可选权限的接口
|
||||
async function fetchAllPermissions() {
|
||||
const permission: SysPermission = {
|
||||
permissionID: undefined,
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
description: undefined,
|
||||
};
|
||||
return await permissionApi.getPermissionList(permission);
|
||||
}
|
||||
|
||||
// 获取菜单已绑定权限的接口
|
||||
async function fetchMenuPermissions() {
|
||||
if (!currentMenu.value?.menuID) {
|
||||
return { success: true, dataList: [] };
|
||||
}
|
||||
return await menuApi.getMenuPermission(currentMenu.value.menuID);
|
||||
}
|
||||
|
||||
// 查看绑定权限
|
||||
async function handleBindPermission(row: SysMenu) {
|
||||
currentMenu.value = row;
|
||||
|
||||
try {
|
||||
// 获取所有权限
|
||||
let permission:SysPermission = {
|
||||
permissionID: undefined,
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
description: undefined,
|
||||
};
|
||||
const permissionResult = await permissionApi.getPermissionList(permission);
|
||||
permissionList.value = permissionResult.dataList || [];
|
||||
|
||||
// 获取已绑定的权限
|
||||
const bindingResult = await menuApi.getMenuPermission(row.menuID!);
|
||||
bindList.value.permissions = bindingResult.dataList || [];
|
||||
|
||||
// 设置已选中的权限
|
||||
selectedPermissions.value = bindList.value.permissions.map(permission => permission.permissionID).filter((id): id is string => !!id);
|
||||
|
||||
console.log('已绑定的权限:', bindList.value.permissions);
|
||||
console.log('所有权限:', permissionList.value);
|
||||
bindPermissionDialogVisible.value = true;
|
||||
} catch (error) {
|
||||
console.error('获取权限绑定信息失败:', error);
|
||||
ElMessage.error('获取权限绑定信息失败');
|
||||
}
|
||||
bindPermissionDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 重置绑定列表
|
||||
function resetBindList() {
|
||||
bindList.value = {
|
||||
permissions: []
|
||||
};
|
||||
selectedPermissions.value = [];
|
||||
currentMenu.value = null;
|
||||
}
|
||||
|
||||
// 检查权限是否已选中
|
||||
function isPermissionSelected(permissionID: string | undefined): boolean {
|
||||
return permissionID ? selectedPermissions.value.includes(permissionID) : false;
|
||||
}
|
||||
|
||||
// 切换权限选择状态
|
||||
function togglePermissionSelection(permission: SysPermission) {
|
||||
if (!permission.permissionID) return;
|
||||
|
||||
const index = selectedPermissions.value.indexOf(permission.permissionID);
|
||||
if (index > -1) {
|
||||
selectedPermissions.value.splice(index, 1);
|
||||
} else {
|
||||
selectedPermissions.value.push(permission.permissionID);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存权限绑定
|
||||
async function savePermissionBinding() {
|
||||
// 权限选择确认 - 在confirm时提交请求
|
||||
async function handlePermissionConfirm(items: SysPermission[]) {
|
||||
if (!currentMenu.value || !currentMenu.value.menuID) {
|
||||
ElMessage.error('菜单信息不完整');
|
||||
return;
|
||||
@@ -600,21 +531,22 @@ async function savePermissionBinding() {
|
||||
try {
|
||||
submitting.value = true;
|
||||
|
||||
// 获取当前已绑定的权限ID
|
||||
const currentBoundPermissions = (bindList.value.permissions || []).map(permission => permission.permissionID).filter((id): id is string => !!id);
|
||||
// 获取当前已绑定的权限
|
||||
const currentBoundResult = await menuApi.getMenuPermission(currentMenu.value.menuID);
|
||||
const currentBoundIds = (currentBoundResult.dataList || []).map(p => p.permissionID).filter((id): id is string => !!id);
|
||||
|
||||
// 新选择的权限ID
|
||||
const newSelectedIds = items.map(p => p.permissionID).filter((id): id is string => !!id);
|
||||
|
||||
// 找出需要绑定的权限(新增的)
|
||||
const permissionsToBind = selectedPermissions.value.filter(permissionID => !currentBoundPermissions.includes(permissionID));
|
||||
const permissionsToBind = newSelectedIds.filter(id => !currentBoundIds.includes(id));
|
||||
|
||||
// 找出需要解绑的权限(移除的)
|
||||
const permissionsToUnbind = currentBoundPermissions.filter(permissionID => !selectedPermissions.value.includes(permissionID));
|
||||
const permissionsToUnbind = currentBoundIds.filter(id => !newSelectedIds.includes(id));
|
||||
|
||||
// 构建需要绑定的权限对象数组
|
||||
if (permissionsToBind.length > 0) {
|
||||
const permissionsToBindObjects = permissionsToBind.map(permissionID => {
|
||||
const permission = permissionList.value.find(p => p.permissionID === permissionID);
|
||||
return permission || { permissionID: permissionID };
|
||||
});
|
||||
const permissionsToBindObjects = items.filter(p => p.permissionID && permissionsToBind.includes(p.permissionID));
|
||||
|
||||
const bindMenu = {
|
||||
...currentMenu.value,
|
||||
@@ -626,10 +558,7 @@ async function savePermissionBinding() {
|
||||
|
||||
// 构建需要解绑的权限对象数组
|
||||
if (permissionsToUnbind.length > 0) {
|
||||
const permissionsToUnbindObjects = permissionsToUnbind.map(permissionID => {
|
||||
const permission = permissionList.value.find(p => p.permissionID === permissionID);
|
||||
return permission || { permissionID: permissionID };
|
||||
});
|
||||
const permissionsToUnbindObjects = (currentBoundResult.dataList || []).filter(p => p.permissionID && permissionsToUnbind.includes(p.permissionID));
|
||||
|
||||
const unbindMenu = {
|
||||
...currentMenu.value,
|
||||
@@ -640,7 +569,6 @@ async function savePermissionBinding() {
|
||||
}
|
||||
|
||||
ElMessage.success('权限绑定保存成功');
|
||||
bindPermissionDialogVisible.value = false;
|
||||
|
||||
// 刷新菜单列表
|
||||
await loadMenuList();
|
||||
@@ -742,8 +670,9 @@ async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string
|
||||
|
||||
.node-icon {
|
||||
margin-right: 8px;
|
||||
color: #409EFF;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
@@ -890,30 +819,4 @@ async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 权限绑定容器样式
|
||||
.permission-binding-container {
|
||||
.menu-info {
|
||||
background: #f5f7fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.binding-stats {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -279,120 +279,34 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定菜单对话框 -->
|
||||
<el-dialog
|
||||
v-model="bindMenuDialogVisible"
|
||||
title="绑定菜单"
|
||||
width="800px"
|
||||
@close="resetBindList"
|
||||
>
|
||||
<div class="menu-binding-container">
|
||||
<div class="permission-info" v-if="currentPermission">
|
||||
<h4>权限信息:{{ currentPermission.name }}</h4>
|
||||
<p>权限编码:{{ currentPermission.code }}</p>
|
||||
</div>
|
||||
|
||||
<el-table :data="menuList" style="width: 100%" border stripe>
|
||||
<el-table-column width="80" label="绑定状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="isMenuSelected(row.menuID) ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ isMenuSelected(row.menuID) ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="菜单名称" min-width="150" />
|
||||
<el-table-column prop="menuID" label="菜单ID" min-width="120" />
|
||||
<el-table-column prop="url" label="菜单路径" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
:type="isMenuSelected(row.menuID) ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
@click="toggleMenuSelection(row)"
|
||||
>
|
||||
{{ isMenuSelected(row.menuID) ? '解绑' : '绑定' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="binding-stats">
|
||||
<el-alert
|
||||
:title="`已绑定 ${selectedMenus.length} 个菜单`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="bindMenuDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveMenuBinding" :loading="submitting">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<GenericSelector
|
||||
v-model:visible="bindMenuDialogVisible"
|
||||
:title="`绑定菜单 - ${currentPermission?.name || ''}`"
|
||||
left-title="可选菜单"
|
||||
right-title="已选菜单"
|
||||
:fetch-available-api="fetchAllMenus"
|
||||
:fetch-selected-api="fetchPermissionMenus"
|
||||
:item-config="{ id: 'menuID', label: 'name', sublabel: 'url' }"
|
||||
unit-name="个"
|
||||
search-placeholder="搜索菜单名称..."
|
||||
@confirm="handleMenuConfirm"
|
||||
@cancel="resetBindList"
|
||||
/>
|
||||
|
||||
<!-- 绑定角色对话框 -->
|
||||
<el-dialog
|
||||
v-model="bindRoleDialogVisible"
|
||||
title="绑定角色"
|
||||
width="800px"
|
||||
@close="resetBindList"
|
||||
>
|
||||
<div class="role-binding-container">
|
||||
<div class="permission-info" v-if="currentPermission">
|
||||
<h4>权限信息:{{ currentPermission.name }}</h4>
|
||||
<p>权限编码:{{ currentPermission.code }}</p>
|
||||
</div>
|
||||
|
||||
<el-table :data="roleList" style="width: 100%" border stripe>
|
||||
<el-table-column width="80" label="绑定状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="isRoleSelected(row.roleID) ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ isRoleSelected(row.roleID) ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="角色名称" min-width="150" />
|
||||
<el-table-column prop="roleID" label="角色ID" min-width="120" />
|
||||
<el-table-column prop="description" label="角色描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
:type="isRoleSelected(row.roleID) ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
@click="toggleRoleSelection(row)"
|
||||
>
|
||||
{{ isRoleSelected(row.roleID) ? '解绑' : '绑定' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="binding-stats">
|
||||
<el-alert
|
||||
:title="`已绑定 ${selectedRoles.length} 个角色`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="bindRoleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRoleBinding" :loading="submitting">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<GenericSelector
|
||||
v-model:visible="bindRoleDialogVisible"
|
||||
:title="`绑定角色 - ${currentPermission?.name || ''}`"
|
||||
left-title="可选角色"
|
||||
right-title="已选角色"
|
||||
:fetch-available-api="fetchAllRoles"
|
||||
:fetch-selected-api="fetchPermissionRoles"
|
||||
:item-config="{ id: 'roleID', label: 'name', sublabel: 'description' }"
|
||||
unit-name="个"
|
||||
search-placeholder="搜索角色名称..."
|
||||
@confirm="handleRoleConfirm"
|
||||
@cancel="resetBindList"
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -407,6 +321,7 @@ import { menuApi } from '@/apis/system/menu';
|
||||
import { roleApi } from '@/apis/system/role';
|
||||
import type { SysModule, SysPermission, SysMenu, SysRole } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
import { GenericSelector } from '@/components/base';
|
||||
|
||||
defineOptions({
|
||||
name: 'ModulePermissionManageView'
|
||||
@@ -418,10 +333,6 @@ const permissionLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const moduleList = ref<SysModule[]>([]);
|
||||
const permissions = ref<SysPermission[]>([]);
|
||||
const menuList = ref<SysMenu[]>([]);
|
||||
const roleList = ref<SysRole[]>([]);
|
||||
const selectedMenus = ref<string[]>([]);
|
||||
const selectedRoles = ref<string[]>([]);
|
||||
|
||||
// 当前选中的模块和权限
|
||||
const currentModule = ref<SysModule | null>(null);
|
||||
@@ -686,80 +597,78 @@ async function handleSubmitPermission() {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有菜单的接口
|
||||
async function fetchAllMenus() {
|
||||
return await menuApi.getAllMenuList();
|
||||
}
|
||||
|
||||
// 获取权限已绑定菜单的接口
|
||||
async function fetchPermissionMenus() {
|
||||
if (!currentPermission.value) {
|
||||
return {
|
||||
success: true,
|
||||
dataList: [],
|
||||
code: 200,
|
||||
message: '',
|
||||
login: true,
|
||||
auth: true
|
||||
};
|
||||
}
|
||||
const permission = { ...currentPermission.value, bindType: 'menu' as const };
|
||||
const result = await permissionApi.getPermissionBindingList(permission);
|
||||
return {
|
||||
code: result.code || 200,
|
||||
message: result.message || '',
|
||||
success: result.success,
|
||||
login: result.login ?? true,
|
||||
auth: result.auth ?? true,
|
||||
dataList: result.data?.menus || []
|
||||
};
|
||||
}
|
||||
|
||||
// 获取所有角色的接口
|
||||
async function fetchAllRoles() {
|
||||
return await roleApi.getAllRoles();
|
||||
}
|
||||
|
||||
// 获取权限已绑定角色的接口
|
||||
async function fetchPermissionRoles() {
|
||||
if (!currentPermission.value) {
|
||||
return {
|
||||
success: true,
|
||||
dataList: [],
|
||||
code: 200,
|
||||
message: '',
|
||||
login: true,
|
||||
auth: true
|
||||
};
|
||||
}
|
||||
const permission = { ...currentPermission.value, bindType: 'role' as const };
|
||||
const result = await permissionApi.getPermissionBindingList(permission);
|
||||
return {
|
||||
code: result.code || 200,
|
||||
message: result.message || '',
|
||||
success: result.success,
|
||||
login: result.login ?? true,
|
||||
auth: result.auth ?? true,
|
||||
dataList: result.data?.roles || []
|
||||
};
|
||||
}
|
||||
|
||||
// 绑定菜单
|
||||
async function handleBindMenu(permission: SysPermission) {
|
||||
currentPermission.value = permission;
|
||||
permission.bindType = 'menu';
|
||||
|
||||
try {
|
||||
const bindingResult = await permissionApi.getPermissionBindingList(permission);
|
||||
const bindList = bindingResult.data?.menus || [];
|
||||
|
||||
const menuResult = await menuApi.getAllMenuList();
|
||||
menuList.value = menuResult.dataList || [];
|
||||
|
||||
selectedMenus.value = bindList.map(menu => menu.menuID).filter((id): id is string => !!id);
|
||||
|
||||
bindMenuDialogVisible.value = true;
|
||||
} catch (error) {
|
||||
console.error('获取菜单绑定信息失败:', error);
|
||||
ElMessage.error('获取菜单绑定信息失败');
|
||||
}
|
||||
bindMenuDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 绑定角色
|
||||
async function handleBindRole(permission: SysPermission) {
|
||||
currentPermission.value = permission;
|
||||
permission.bindType = 'role';
|
||||
|
||||
try {
|
||||
const bindingResult = await permissionApi.getPermissionBindingList(permission);
|
||||
const bindList = bindingResult.data?.roles || [];
|
||||
|
||||
const roleResult = await roleApi.getAllRoles();
|
||||
roleList.value = roleResult.dataList || [];
|
||||
|
||||
selectedRoles.value = bindList.map(role => role.roleID).filter((id): id is string => !!id);
|
||||
|
||||
bindRoleDialogVisible.value = true;
|
||||
} catch (error) {
|
||||
console.error('获取角色绑定信息失败:', error);
|
||||
ElMessage.error('获取角色绑定信息失败');
|
||||
}
|
||||
bindRoleDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 菜单选择相关
|
||||
function isMenuSelected(menuID: string | undefined): boolean {
|
||||
return menuID ? selectedMenus.value.includes(menuID) : false;
|
||||
}
|
||||
|
||||
function toggleMenuSelection(menu: SysMenu) {
|
||||
if (!menu.menuID) return;
|
||||
const index = selectedMenus.value.indexOf(menu.menuID);
|
||||
if (index > -1) {
|
||||
selectedMenus.value.splice(index, 1);
|
||||
} else {
|
||||
selectedMenus.value.push(menu.menuID);
|
||||
}
|
||||
}
|
||||
|
||||
// 角色选择相关
|
||||
function isRoleSelected(roleID: string | undefined): boolean {
|
||||
return roleID ? selectedRoles.value.includes(roleID) : false;
|
||||
}
|
||||
|
||||
function toggleRoleSelection(role: SysRole) {
|
||||
if (!role.roleID) return;
|
||||
const index = selectedRoles.value.indexOf(role.roleID);
|
||||
if (index > -1) {
|
||||
selectedRoles.value.splice(index, 1);
|
||||
} else {
|
||||
selectedRoles.value.push(role.roleID);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存菜单绑定
|
||||
async function saveMenuBinding() {
|
||||
// 菜单选择确认 - 在confirm时提交请求
|
||||
async function handleMenuConfirm(items: SysMenu[]) {
|
||||
if (!currentPermission.value?.permissionID) {
|
||||
ElMessage.error('权限信息不完整');
|
||||
return;
|
||||
@@ -768,16 +677,20 @@ async function saveMenuBinding() {
|
||||
try {
|
||||
submitting.value = true;
|
||||
|
||||
// 获取原有绑定和新绑定的差异
|
||||
// 获取当前已绑定的菜单
|
||||
const permission: SysPermission = { ...currentPermission.value, bindType: 'menu' };
|
||||
const bindingResult = await permissionApi.getPermissionBindingList(permission);
|
||||
const currentBound = (bindingResult.data?.menus || []).map(m => m.menuID).filter((id): id is string => !!id);
|
||||
const currentBoundIds = (bindingResult.data?.menus || []).map(m => m.menuID).filter((id): id is string => !!id);
|
||||
|
||||
const menusToBind = selectedMenus.value.filter(id => !currentBound.includes(id));
|
||||
const menusToUnbind = currentBound.filter(id => !selectedMenus.value.includes(id));
|
||||
// 新选择的菜单ID
|
||||
const newSelectedIds = items.map(m => m.menuID).filter((id): id is string => !!id);
|
||||
|
||||
// 找出需要绑定和解绑的菜单
|
||||
const menusToBind = newSelectedIds.filter(id => !currentBoundIds.includes(id));
|
||||
const menusToUnbind = currentBoundIds.filter(id => !newSelectedIds.includes(id));
|
||||
|
||||
if (menusToBind.length > 0) {
|
||||
const menusToBindObjects = menusToBind.map(id => ({ menuID: id }));
|
||||
const menusToBindObjects = items.filter(m => m.menuID && menusToBind.includes(m.menuID));
|
||||
await permissionApi.bindMenu({
|
||||
...currentPermission.value,
|
||||
menus: menusToBindObjects,
|
||||
@@ -795,7 +708,6 @@ async function saveMenuBinding() {
|
||||
}
|
||||
|
||||
ElMessage.success('菜单绑定保存成功');
|
||||
bindMenuDialogVisible.value = false;
|
||||
} catch (error) {
|
||||
console.error('保存菜单绑定失败:', error);
|
||||
ElMessage.error('保存菜单绑定失败');
|
||||
@@ -804,8 +716,8 @@ async function saveMenuBinding() {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存角色绑定
|
||||
async function saveRoleBinding() {
|
||||
// 角色选择确认 - 在confirm时提交请求
|
||||
async function handleRoleConfirm(items: SysRole[]) {
|
||||
if (!currentPermission.value?.permissionID) {
|
||||
ElMessage.error('权限信息不完整');
|
||||
return;
|
||||
@@ -814,15 +726,20 @@ async function saveRoleBinding() {
|
||||
try {
|
||||
submitting.value = true;
|
||||
|
||||
// 获取当前已绑定的角色
|
||||
const permission: SysPermission = { ...currentPermission.value, bindType: 'role' };
|
||||
const bindingResult = await permissionApi.getPermissionBindingList(permission);
|
||||
const currentBound = (bindingResult.data?.roles || []).map(r => r.roleID).filter((id): id is string => !!id);
|
||||
const currentBoundIds = (bindingResult.data?.roles || []).map(r => r.roleID).filter((id): id is string => !!id);
|
||||
|
||||
const rolesToBind = selectedRoles.value.filter(id => !currentBound.includes(id));
|
||||
const rolesToUnbind = currentBound.filter(id => !selectedRoles.value.includes(id));
|
||||
// 新选择的角色ID
|
||||
const newSelectedIds = items.map(r => r.roleID).filter((id): id is string => !!id);
|
||||
|
||||
// 找出需要绑定和解绑的角色
|
||||
const rolesToBind = newSelectedIds.filter(id => !currentBoundIds.includes(id));
|
||||
const rolesToUnbind = currentBoundIds.filter(id => !newSelectedIds.includes(id));
|
||||
|
||||
if (rolesToBind.length > 0) {
|
||||
const rolesToBindObjects = rolesToBind.map(id => ({ roleID: id }));
|
||||
const rolesToBindObjects = items.filter(r => r.roleID && rolesToBind.includes(r.roleID));
|
||||
await permissionApi.bindRole({
|
||||
...currentPermission.value,
|
||||
roles: rolesToBindObjects,
|
||||
@@ -840,7 +757,6 @@ async function saveRoleBinding() {
|
||||
}
|
||||
|
||||
ElMessage.success('角色绑定保存成功');
|
||||
bindRoleDialogVisible.value = false;
|
||||
} catch (error) {
|
||||
console.error('保存角色绑定失败:', error);
|
||||
ElMessage.error('保存角色绑定失败');
|
||||
@@ -870,8 +786,6 @@ function resetPermissionForm() {
|
||||
}
|
||||
|
||||
function resetBindList() {
|
||||
selectedMenus.value = [];
|
||||
selectedRoles.value = [];
|
||||
currentPermission.value = null;
|
||||
}
|
||||
|
||||
@@ -1138,32 +1052,6 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定对话框
|
||||
.menu-binding-container,
|
||||
.role-binding-container {
|
||||
.permission-info {
|
||||
background: #f5f7fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.binding-stats {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -97,61 +97,19 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定权限对话框 -->
|
||||
<el-dialog v-model="bindPermissionDialogVisible" title="绑定权限" width="800px" @close="resetBindList">
|
||||
<div class="permission-binding-container">
|
||||
<!-- 角色信息显示 -->
|
||||
<div class="role-info" v-if="currentRole">
|
||||
<h4>角色信息:{{ currentRole.name }}</h4>
|
||||
<p>角色ID:{{ currentRole.roleID }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 权限绑定状态表格 -->
|
||||
<el-table :data="permissionList" style="width: 100%" border stripe>
|
||||
<el-table-column width="80" label="绑定状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="isPermissionSelected(row.permissionID) ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ isPermissionSelected(row.permissionID) ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="权限名称" min-width="150" />
|
||||
<el-table-column prop="permissionID" label="权限ID" min-width="120" />
|
||||
<el-table-column prop="code" label="权限编码" min-width="150" />
|
||||
<el-table-column prop="description" label="权限描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
:type="isPermissionSelected(row.permissionID) ? 'danger' : 'primary'"
|
||||
size="small"
|
||||
@click="togglePermissionSelection(row)"
|
||||
>
|
||||
{{ isPermissionSelected(row.permissionID) ? '解绑' : '绑定' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="binding-stats">
|
||||
<el-alert
|
||||
:title="`已绑定 ${selectedPermissions.length} 个权限,未绑定 ${permissionList.length - selectedPermissions.length} 个权限`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="bindPermissionDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="savePermissionBinding" :loading="submitting">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<GenericSelector
|
||||
v-model:visible="bindPermissionDialogVisible"
|
||||
:title="`绑定权限 - ${currentRole?.name || ''}`"
|
||||
left-title="可选权限"
|
||||
right-title="已选权限"
|
||||
:available-items="availablePermissions"
|
||||
:initial-target-items="initialBoundPermissions"
|
||||
:item-config="{ id: 'permissionID', label: 'name', sublabel: 'code' }"
|
||||
unit-name="个"
|
||||
search-placeholder="搜索权限名称或编码..."
|
||||
@confirm="handlePermissionConfirm"
|
||||
@cancel="resetBindList"
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -161,11 +119,12 @@ import { roleApi } from '@/apis/system/role';
|
||||
import { permissionApi } from '@/apis/system/permission';
|
||||
import { SysRole, SysPermission } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
import { GenericSelector } from '@/components/base';
|
||||
|
||||
defineOptions({
|
||||
name: 'RoleManageView'
|
||||
});
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import { ref, onMounted, reactive, computed } from 'vue';
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
|
||||
@@ -176,11 +135,8 @@ const submitting = ref(false);
|
||||
|
||||
// 权限绑定相关数据
|
||||
const permissionList = ref<SysPermission[]>([]);
|
||||
const selectedPermissions = ref<string[]>([]);
|
||||
const currentRole = ref<SysRole | null>(null);
|
||||
const bindList = ref<{ permissions: SysPermission[] }>({
|
||||
permissions: []
|
||||
});
|
||||
const initialBoundPermissions = ref<SysPermission[]>([]);
|
||||
|
||||
// 对话框状态
|
||||
const dialogVisible = ref(false);
|
||||
@@ -295,6 +251,12 @@ function resetForm() {
|
||||
});
|
||||
}
|
||||
|
||||
// 计算可选权限(过滤掉已绑定的)
|
||||
const availablePermissions = computed(() => {
|
||||
const boundIds = new Set(initialBoundPermissions.value.map(p => p.permissionID));
|
||||
return permissionList.value.filter(p => !boundIds.has(p.permissionID));
|
||||
});
|
||||
|
||||
// 查看绑定权限
|
||||
async function handleBindPermission(row: SysRole) {
|
||||
currentRole.value = row;
|
||||
@@ -314,14 +276,8 @@ async function handleBindPermission(row: SysRole) {
|
||||
const bindingResult = await roleApi.getRolePermission({
|
||||
roleID: row.roleID
|
||||
});
|
||||
console.log('已绑定的权限:', bindingResult);
|
||||
bindList.value.permissions = bindingResult.dataList || [];
|
||||
initialBoundPermissions.value = bindingResult.dataList || [];
|
||||
|
||||
// 设置已选中的权限
|
||||
selectedPermissions.value = bindList.value.permissions.map(permission => permission.permissionID).filter((id): id is string => !!id);
|
||||
|
||||
console.log('已绑定的权限:', bindList.value.permissions);
|
||||
console.log('所有权限:', permissionList.value);
|
||||
bindPermissionDialogVisible.value = true;
|
||||
} catch (error) {
|
||||
console.error('获取权限绑定信息失败:', error);
|
||||
@@ -331,32 +287,12 @@ async function handleBindPermission(row: SysRole) {
|
||||
|
||||
// 重置绑定列表
|
||||
function resetBindList() {
|
||||
bindList.value = {
|
||||
permissions: []
|
||||
};
|
||||
selectedPermissions.value = [];
|
||||
initialBoundPermissions.value = [];
|
||||
currentRole.value = null;
|
||||
}
|
||||
|
||||
// 检查权限是否已选中
|
||||
function isPermissionSelected(permissionID: string | undefined): boolean {
|
||||
return permissionID ? selectedPermissions.value.includes(permissionID) : false;
|
||||
}
|
||||
|
||||
// 切换权限选择状态
|
||||
function togglePermissionSelection(permission: SysPermission) {
|
||||
if (!permission.permissionID) return;
|
||||
|
||||
const index = selectedPermissions.value.indexOf(permission.permissionID);
|
||||
if (index > -1) {
|
||||
selectedPermissions.value.splice(index, 1);
|
||||
} else {
|
||||
selectedPermissions.value.push(permission.permissionID);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存权限绑定
|
||||
async function savePermissionBinding() {
|
||||
// 权限选择确认 - 在confirm时提交请求
|
||||
async function handlePermissionConfirm(items: SysPermission[]) {
|
||||
if (!currentRole.value || !currentRole.value.roleID) {
|
||||
ElMessage.error('角色信息不完整');
|
||||
return;
|
||||
@@ -366,13 +302,16 @@ async function savePermissionBinding() {
|
||||
submitting.value = true;
|
||||
|
||||
// 获取当前已绑定的权限ID
|
||||
const currentBoundPermissions = (bindList.value.permissions || []).map(permission => permission.permissionID).filter((id): id is string => !!id);
|
||||
const currentBoundIds = initialBoundPermissions.value.map(p => p.permissionID).filter((id): id is string => !!id);
|
||||
|
||||
// 新选择的权限ID
|
||||
const newSelectedIds = items.map(p => p.permissionID).filter((id): id is string => !!id);
|
||||
|
||||
// 找出需要绑定的权限(新增的)
|
||||
const permissionsToBind = selectedPermissions.value.filter(permissionID => !currentBoundPermissions.includes(permissionID));
|
||||
const permissionsToBind = newSelectedIds.filter(id => !currentBoundIds.includes(id));
|
||||
|
||||
// 找出需要解绑的权限(移除的)
|
||||
const permissionsToUnbind = currentBoundPermissions.filter(permissionID => !selectedPermissions.value.includes(permissionID));
|
||||
const permissionsToUnbind = currentBoundIds.filter(id => !newSelectedIds.includes(id));
|
||||
|
||||
// 构建需要绑定的权限对象数组
|
||||
if (permissionsToBind.length > 0) {
|
||||
@@ -405,7 +344,6 @@ async function savePermissionBinding() {
|
||||
}
|
||||
|
||||
ElMessage.success('权限绑定保存成功');
|
||||
bindPermissionDialogVisible.value = false;
|
||||
|
||||
// 刷新角色列表
|
||||
await loadRoleList();
|
||||
@@ -515,30 +453,4 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 权限绑定容器样式
|
||||
.permission-binding-container {
|
||||
.role-info {
|
||||
background: #f5f7fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.binding-stats {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -129,45 +129,25 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定部门角色对话框 -->
|
||||
<el-dialog
|
||||
v-model="bindDialogVisible"
|
||||
title="绑定部门角色"
|
||||
width="600px"
|
||||
@close="resetBindForm"
|
||||
>
|
||||
<el-form
|
||||
ref="bindFormRef"
|
||||
:model="bindForm"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="选择部门">
|
||||
<el-tree-select
|
||||
v-model="bindForm.deptId"
|
||||
:data="deptTree"
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
placeholder="请选择部门"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择角色">
|
||||
<el-select v-model="bindForm.roleIds" multiple placeholder="请选择角色" style="width: 100%">
|
||||
<el-option
|
||||
v-for="role in roles"
|
||||
:key="role.roleID"
|
||||
:label="role.name"
|
||||
:value="role.roleID"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="bindDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitBindForm" :loading="binding">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 部门角色选择器 -->
|
||||
<GenericSelector
|
||||
v-model:visible="showDeptRoleSelector"
|
||||
:title="`绑定部门角色 - ${currentUser.username || ''}`"
|
||||
left-title="可选的部门角色组合"
|
||||
right-title="已选的部门角色"
|
||||
:fetch-available-api="fetchAllDeptRoles"
|
||||
:fetch-selected-api="fetchUserDeptRoles"
|
||||
:filter-selected="filterDeptRoles"
|
||||
:item-config="{ id: 'combinedId', label: 'displayName', sublabel: 'deptDescription' }"
|
||||
:use-tree="true"
|
||||
:tree-transform="transformDeptRolesToTree"
|
||||
:tree-props="{ children: 'children', label: 'displayName', id: 'combinedId' }"
|
||||
:only-leaf-selectable="true"
|
||||
unit-name="个"
|
||||
search-placeholder="搜索部门或角色..."
|
||||
@confirm="handleDeptRoleConfirm"
|
||||
@cancel="closeDeptRoleSelector"
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
@@ -176,9 +156,10 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import { userApi, deptApi, roleApi } from '@/apis/system';
|
||||
import type { SysUser, SysRole, PageParam, UserVO, UserDeptRoleVO } from '@/types';
|
||||
import { userApi, deptApi } from '@/apis/system';
|
||||
import type { SysUser, PageParam, UserVO, UserDeptRoleVO, SysUserDeptRole } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
import { GenericSelector } from '@/components/base';
|
||||
|
||||
defineOptions({
|
||||
name: 'UserManageView'
|
||||
@@ -189,8 +170,6 @@ const submitting = ref(false);
|
||||
const binding = ref(false);
|
||||
|
||||
const userList = ref<UserVO[]>([]);
|
||||
const deptTree = ref<any[]>([]);
|
||||
const roles = ref<SysRole[]>([]);
|
||||
|
||||
// 分页参数
|
||||
const pageParam = ref<PageParam>({
|
||||
@@ -200,23 +179,147 @@ const pageParam = ref<PageParam>({
|
||||
const total = ref(0);
|
||||
|
||||
const userDialogVisible = ref(false);
|
||||
const bindDialogVisible = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
const userFormRef = ref<FormInstance>();
|
||||
const bindFormRef = ref<FormInstance>();
|
||||
|
||||
const currentUser = ref<UserVO & { password?: string }>({
|
||||
status: 0
|
||||
});
|
||||
|
||||
const bindForm = ref<{
|
||||
userId?: string;
|
||||
deptId?: string;
|
||||
roleIds: string[];
|
||||
}>({
|
||||
roleIds: []
|
||||
});
|
||||
// 保存原始用户数据,用于比较变更
|
||||
const originalUser = ref<UserVO & { password?: string }>({});
|
||||
|
||||
// 选择器控制
|
||||
const showDeptRoleSelector = ref(false);
|
||||
const currentBindingUserId = ref<string>();
|
||||
|
||||
// 获取所有部门角色组合的接口
|
||||
async function fetchAllDeptRoles() {
|
||||
const result = await deptApi.getDeptRoleList({} as SysUserDeptRole);
|
||||
if (result.success) {
|
||||
const deptRoleList = result.dataList || [];
|
||||
// 转换为带有combinedId和displayName的格式
|
||||
const transformed = deptRoleList
|
||||
.filter(item => item.deptID && item.roleID)
|
||||
.map(item => ({
|
||||
...item,
|
||||
combinedId: `${item.deptID}-${item.roleID}`,
|
||||
displayName: `${item.deptName || ''} - ${item.roleName || ''}`
|
||||
}));
|
||||
return { ...result, dataList: transformed };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 获取用户已选的部门角色接口
|
||||
async function fetchUserDeptRoles() {
|
||||
if (!currentBindingUserId.value) {
|
||||
return {
|
||||
success: true,
|
||||
dataList: [],
|
||||
code: 200,
|
||||
message: '',
|
||||
login: true,
|
||||
auth: true
|
||||
};
|
||||
}
|
||||
const result = await userApi.getUserDeptRole({ userID: currentBindingUserId.value } as SysUserDeptRole);
|
||||
if (result.success) {
|
||||
const selectedList = result.dataList || [];
|
||||
// 转换为带有combinedId和displayName的格式
|
||||
const transformed = selectedList.map(item => ({
|
||||
...item,
|
||||
combinedId: `${item.deptID}-${item.roleID}`,
|
||||
displayName: `${item.deptName || ''} - ${item.roleName || ''}`
|
||||
}));
|
||||
return { ...result, dataList: transformed };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 过滤已选项的方法
|
||||
function filterDeptRoles(available: any[], selected: any[]) {
|
||||
const selectedIds = new Set(selected.map(item => item.combinedId));
|
||||
return available.filter(item => !selectedIds.has(item.combinedId));
|
||||
}
|
||||
|
||||
// 将部门角色扁平数据转换为树形结构
|
||||
function transformDeptRolesToTree(flatData: any[]) {
|
||||
if (!flatData || flatData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 按部门分组
|
||||
const deptMap = new Map<string, any>();
|
||||
const tree: any[] = [];
|
||||
|
||||
flatData.forEach(item => {
|
||||
if (!item.deptID) return;
|
||||
|
||||
if (!deptMap.has(item.deptID)) {
|
||||
// 创建部门节点
|
||||
deptMap.set(item.deptID, {
|
||||
combinedId: item.deptID,
|
||||
displayName: item.deptName || '',
|
||||
deptID: item.deptID,
|
||||
deptName: item.deptName,
|
||||
parentID: item.parentID,
|
||||
deptPath: item.deptPath,
|
||||
children: [],
|
||||
isDept: true // 标记这是部门节点
|
||||
});
|
||||
}
|
||||
|
||||
// 添加角色到部门的children中
|
||||
const deptNode = deptMap.get(item.deptID);
|
||||
if (deptNode && item.roleID) {
|
||||
deptNode.children.push({
|
||||
...item,
|
||||
isDept: false // 标记这是角色节点
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 构建树形结构
|
||||
const allDepts = Array.from(deptMap.values());
|
||||
const deptTreeMap = new Map<string, any>();
|
||||
|
||||
// 初始化所有部门节点
|
||||
allDepts.forEach(dept => {
|
||||
deptTreeMap.set(dept.deptID, { ...dept });
|
||||
});
|
||||
|
||||
// 构建部门层级关系
|
||||
allDepts.forEach(dept => {
|
||||
const node = deptTreeMap.get(dept.deptID);
|
||||
if (!node) return;
|
||||
|
||||
if (!dept.parentID || dept.parentID === '0' || dept.parentID === '') {
|
||||
// 根部门
|
||||
tree.push(node);
|
||||
} else {
|
||||
// 子部门
|
||||
const parent = deptTreeMap.get(dept.parentID);
|
||||
if (parent) {
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
// 将角色节点添加到部门节点之前
|
||||
const roles = node.children || [];
|
||||
node.children = [];
|
||||
parent.children.push(node);
|
||||
// 将角色添加到部门的children末尾
|
||||
node.children = roles;
|
||||
} else {
|
||||
// 找不到父节点,作为根节点
|
||||
tree.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
const userFormRules: FormRules = {
|
||||
username: [
|
||||
@@ -232,10 +335,9 @@ const userFormRules: FormRules = {
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
loadDepts();
|
||||
loadRoles();
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
@@ -254,37 +356,20 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepts() {
|
||||
try {
|
||||
const result = await deptApi.getAllDepts();
|
||||
if (result.success) {
|
||||
deptTree.value = result.dataList || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载部门列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
try {
|
||||
const result = await roleApi.getRoleList({});
|
||||
if (result.success) {
|
||||
roles.value = result.dataList || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载角色列表失败:', error);
|
||||
}
|
||||
}
|
||||
// 不再需要预加载,由GenericSelector在打开时自动调用接口加载
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false;
|
||||
currentUser.value = { status: 0 };
|
||||
originalUser.value = {};
|
||||
userDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(row: UserVO) {
|
||||
isEdit.value = true;
|
||||
currentUser.value = { ...row };
|
||||
// 保存原始数据用于比较
|
||||
originalUser.value = { ...row };
|
||||
userDialogVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -316,12 +401,76 @@ async function handleDelete(row: UserVO) {
|
||||
}
|
||||
|
||||
function handleBindDeptRole(row: UserVO) {
|
||||
bindForm.value = {
|
||||
userId: row.id,
|
||||
deptId: undefined,
|
||||
roleIds: []
|
||||
};
|
||||
bindDialogVisible.value = true;
|
||||
currentBindingUserId.value = row.id;
|
||||
currentUser.value = { ...row };
|
||||
showDeptRoleSelector.value = true;
|
||||
}
|
||||
|
||||
// 部门角色选择确认 - 在confirm时提交请求
|
||||
async function handleDeptRoleConfirm(items: any[]) {
|
||||
if (!currentBindingUserId.value) {
|
||||
ElMessage.error('用户信息不完整');
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
ElMessage.warning('请至少选择一个部门角色');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
binding.value = true;
|
||||
|
||||
// 移除临时添加的字段,只保留原始字段
|
||||
const userDeptRoles: SysUserDeptRole[] = items.map(item => ({
|
||||
deptID: item.deptID,
|
||||
roleID: item.roleID,
|
||||
userID: currentBindingUserId.value
|
||||
}));
|
||||
|
||||
// 构建 UserDeptRoleVO 对象(用于批量绑定)
|
||||
const userDeptRoleVO = {
|
||||
users: [{ id: currentBindingUserId.value } as SysUser],
|
||||
userDeptRoles: userDeptRoles
|
||||
} as UserDeptRoleVO;
|
||||
|
||||
const result = await userApi.bindUserDeptRole(userDeptRoleVO);
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success(`成功绑定 ${items.length} 个部门角色`);
|
||||
loadUsers();
|
||||
} else {
|
||||
ElMessage.error(result.message || '绑定失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('绑定失败:', error);
|
||||
ElMessage.error('绑定失败');
|
||||
} finally {
|
||||
binding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭部门角色选择器
|
||||
function closeDeptRoleSelector() {
|
||||
currentBindingUserId.value = undefined;
|
||||
}
|
||||
|
||||
// 获取修改过的字段
|
||||
function getChangedFields(): Partial<SysUser> {
|
||||
const changed: Partial<SysUser> = { id: currentUser.value.id };
|
||||
|
||||
// 比较每个字段,只包含修改过的字段
|
||||
Object.keys(currentUser.value).forEach((key) => {
|
||||
const currentValue = (currentUser.value as any)[key];
|
||||
const originalValue = (originalUser.value as any)[key];
|
||||
|
||||
// 如果值发生变化,则添加到变更对象中
|
||||
if (currentValue !== originalValue) {
|
||||
(changed as any)[key] = currentValue;
|
||||
}
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
async function submitUserForm() {
|
||||
@@ -333,7 +482,10 @@ async function submitUserForm() {
|
||||
|
||||
let result;
|
||||
if (isEdit.value) {
|
||||
result = await userApi.updateUser(currentUser.value as SysUser);
|
||||
// 只传入修改过的字段
|
||||
const changedData = getChangedFields();
|
||||
console.log('更新用户 - 修改的字段:', changedData);
|
||||
result = await userApi.updateUser(changedData as SysUser);
|
||||
} else {
|
||||
result = await userApi.createUser(currentUser.value as SysUser);
|
||||
}
|
||||
@@ -352,40 +504,11 @@ async function submitUserForm() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBindForm() {
|
||||
try {
|
||||
binding.value = true;
|
||||
|
||||
// 构建 UserDeptRoleVO 对象
|
||||
const userDeptRoleVO: UserDeptRoleVO = {
|
||||
user: { id: bindForm.value.userId } as SysUser,
|
||||
depts: bindForm.value.deptId ? [{ id: bindForm.value.deptId }] : [],
|
||||
roles: bindForm.value.roleIds.map(roleId => ({ id: roleId }))
|
||||
};
|
||||
|
||||
const result = await userApi.bindUserDeptRole(userDeptRoleVO);
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('绑定成功');
|
||||
bindDialogVisible.value = false;
|
||||
loadUsers();
|
||||
} else {
|
||||
ElMessage.error(result.message || '绑定失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('绑定失败:', error);
|
||||
ElMessage.error('绑定失败');
|
||||
} finally {
|
||||
binding.value = false;
|
||||
}
|
||||
}
|
||||
// submitBindForm 已合并到 handleDeptRoleConfirm 中
|
||||
|
||||
function resetForm() {
|
||||
userFormRef.value?.resetFields();
|
||||
}
|
||||
|
||||
function resetBindForm() {
|
||||
bindFormRef.value?.resetFields();
|
||||
originalUser.value = {};
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
|
||||
@@ -76,14 +76,16 @@
|
||||
<td>{{ formatDate(task.endTime) }}</td>
|
||||
<td>
|
||||
<span class="status-tag" :class="getStatusClass(task.status)">
|
||||
{{ getStatusText(task.status) }}
|
||||
{{ getStatusText(task.status, task.startTime, task.endTime) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(task.createTime) }}</td>
|
||||
<td class="action-cell">
|
||||
<button class="btn-link btn-primary" @click="handleView(task)">查看</button>
|
||||
<button class="btn-link btn-warning" @click="handleEdit(task)" v-if="task.status === 0">编辑</button>
|
||||
<button class="btn-link btn-success" @click="handlePublish(task)" v-if="task.status === 0">发布</button>
|
||||
<button class="btn-link btn-success" @click="handleStateChange(task, 'publish')" v-if="task.status !== 1">发布</button>
|
||||
<button class="btn-link btn-warning" @click="handleStateChange(task, 'unpublish')" v-if="task.status === 1">下架</button>
|
||||
<button class="btn-link btn-primary" @click="handleStatistics(task)">统计</button>
|
||||
<button class="btn-link btn-warning" @click="handleUpdateUser(task)" v-if="task.status !== 2">修改人员</button>
|
||||
<button class="btn-link btn-danger" @click="handleDelete(task)" v-if="task.status === 0">删除</button>
|
||||
</td>
|
||||
@@ -184,7 +186,7 @@
|
||||
<span class="detail-label">任务状态:</span>
|
||||
<span class="detail-value">
|
||||
<span class="status-badge" :class="getStatusClass(viewingTask.learningTask.status)">
|
||||
{{ getStatusText(viewingTask.learningTask.status) }}
|
||||
{{ getStatusText(viewingTask.learningTask.status, viewingTask.learningTask.startTime, viewingTask.learningTask.endTime) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -311,15 +313,18 @@
|
||||
</div>
|
||||
|
||||
<!-- 人员选择器组件 -->
|
||||
<UserSelect
|
||||
<GenericSelector
|
||||
v-model:visible="showUserSelector"
|
||||
:mode="selectorMode"
|
||||
:title="selectorMode === 'add' ? '添加人员' : '删除人员'"
|
||||
:left-title="selectorMode === 'add' ? '可添加人员' : '当前人员'"
|
||||
:right-title="selectorMode === 'add' ? '待添加人员' : '待删除人员'"
|
||||
:available-users="selectorMode === 'remove' ? availableUsers : []"
|
||||
:initial-target-users="[]"
|
||||
:available-items="selectorMode === 'remove' ? availableUsers : []"
|
||||
:initial-target-items="[]"
|
||||
:loading="saving"
|
||||
:item-config="{ id: 'id', label: 'username', sublabel: 'deptName' }"
|
||||
unit-name="人"
|
||||
search-placeholder="搜索人员..."
|
||||
:use-pagination="selectorMode === 'add'"
|
||||
:fetch-api="selectorMode === 'add' ? userApi.getUserPage : undefined"
|
||||
:filter-params="userFilterParams"
|
||||
@@ -335,7 +340,7 @@ import { ref, computed, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { learningTaskApi } from '@/apis/study';
|
||||
import { userApi } from '@/apis/system';
|
||||
import { UserSelect } from '@/components';
|
||||
import { GenericSelector } from '@/components/base';
|
||||
import type { LearningTask, TaskVO, PageParam, UserVO } from '@/types';
|
||||
|
||||
defineOptions({
|
||||
@@ -518,6 +523,10 @@ function handleEdit(task: LearningTask) {
|
||||
emit('edit', task);
|
||||
}
|
||||
|
||||
function handleStatistics(task: LearningTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 修改人员 - 显示当前人员列表
|
||||
async function handleUpdateUser(task: LearningTask) {
|
||||
managingTask.value = task;
|
||||
@@ -583,7 +592,7 @@ function closeSelectorModal() {
|
||||
}
|
||||
|
||||
// 处理用户选择器确认事件
|
||||
async function handleUserSelectConfirm(selectedUsers: UserVO[]) {
|
||||
async function handleUserSelectConfirm(selectedUsers: any[]) {
|
||||
if (!managingTask.value || selectedUsers.length === 0) {
|
||||
ElMessage.warning('请选择要操作的人员');
|
||||
return;
|
||||
@@ -602,7 +611,7 @@ async function handleUserSelectConfirm(selectedUsers: UserVO[]) {
|
||||
ElMessage.success(`成功添加 ${userIds.length} 位人员`);
|
||||
|
||||
// 更新当前用户列表
|
||||
currentUsers.value.push(...selectedUsers);
|
||||
currentUsers.value.push(...(selectedUsers as UserVO[]));
|
||||
} else {
|
||||
// 执行删除操作
|
||||
for (const userID of userIds) {
|
||||
@@ -627,21 +636,21 @@ async function handleUserSelectConfirm(selectedUsers: UserVO[]) {
|
||||
}
|
||||
|
||||
// 发布任务
|
||||
async function handlePublish(task: LearningTask) {
|
||||
async function handleStateChange(task: LearningTask, state: 'publish' | 'unpublish') {
|
||||
try {
|
||||
const res = await learningTaskApi.publishTask({
|
||||
taskID: task.taskID!,
|
||||
status: 1
|
||||
const res = await learningTaskApi.changeTaskStatus({
|
||||
...task,
|
||||
status: state === 'publish' ? 1 : 2
|
||||
});
|
||||
if (res.success) {
|
||||
ElMessage.success('任务发布成功');
|
||||
ElMessage.success('任务状态更新成功');
|
||||
loadTaskList();
|
||||
} else {
|
||||
ElMessage.error(res.message || '发布失败');
|
||||
ElMessage.error(res.message || '状态更新失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发布任务失败:', error);
|
||||
ElMessage.error('发布任务失败');
|
||||
console.error('状态更新失败:', error);
|
||||
ElMessage.error('状态更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,18 +698,27 @@ function getStatusClass(status?: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
function getStatusText(status?: number) {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return '草稿';
|
||||
case 1:
|
||||
return '进行中';
|
||||
case 2:
|
||||
return '已结束';
|
||||
default:
|
||||
return '未知';
|
||||
|
||||
function getStatusText(status?: number, startTime?: string, endTime?: string): string {
|
||||
if (status === 0) {
|
||||
return '草稿';
|
||||
}
|
||||
if (status === 1) {
|
||||
let now = new Date();
|
||||
let startTimeDate = new Date(startTime!);
|
||||
let endTimeDate = new Date(endTime!);
|
||||
if (now >= startTimeDate && now <= endTimeDate) {
|
||||
return '进行中';
|
||||
} else if (now < startTimeDate) {
|
||||
return '未开始';
|
||||
} else {
|
||||
return '已结束';
|
||||
}
|
||||
}
|
||||
if (status === 2) {
|
||||
return '下架';
|
||||
}
|
||||
return '未知';
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
@@ -810,28 +828,6 @@ defineExpose({
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.task-table-wrapper {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
@@ -913,46 +909,45 @@ defineExpose({
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border-radius: 4px;
|
||||
min-width: 64px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
&:hover {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
background: #409eff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&.btn-warning {
|
||||
color: #e6a23c;
|
||||
|
||||
&:hover {
|
||||
background: #fdf6ec;
|
||||
}
|
||||
background: #e6a23c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&.btn-success {
|
||||
color: #67c23a;
|
||||
|
||||
&:hover {
|
||||
background: #f0f9ff;
|
||||
}
|
||||
background: #67c23a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background: #f56c6c;
|
||||
color: #ffffff;
|
||||
|
||||
&:hover {
|
||||
background: #fef0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,9 +1068,11 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
// 通用按钮样式
|
||||
.btn-default,
|
||||
.btn-danger {
|
||||
// 通用按钮样式(排除表格中的 btn-link)
|
||||
.btn-primary:not(.btn-link),
|
||||
.btn-success:not(.btn-link),
|
||||
.btn-danger:not(.btn-link),
|
||||
.btn-default:not(.btn-link) {
|
||||
padding: 10px 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
@@ -1083,13 +1080,44 @@ defineExpose({
|
||||
transition: all 0.3s;
|
||||
border: none;
|
||||
|
||||
.icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
.btn-primary:not(.btn-link) {
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #66b1ff;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-success:not(.btn-link) {
|
||||
background: #67c23a;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #85ce61;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger:not(.btn-link) {
|
||||
background: #f56c6c;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f78989;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-default:not(.btn-link) {
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
border: 1px solid #dcdfe6;
|
||||
@@ -1100,15 +1128,6 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f56c6c;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f78989;
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗样式
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
@@ -1300,46 +1319,6 @@ defineExpose({
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn-success,
|
||||
.btn-danger {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #67c23a;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #85ce61;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f56c6c;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f78989;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.current-user-list {
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
Reference in New Issue
Block a user