serv\web- 多租户修改

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

View File

@@ -27,7 +27,7 @@
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.32.7",
"typescript": "~4.5.5",
"typescript": "^5.2.2",
"vite": "^5.0.0",
"vite-plugin-pwa": "^0.19.0"
}
@@ -7505,9 +7505,9 @@
}
},
"node_modules/typescript": {
"version": "4.5.5",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-4.5.5.tgz",
"integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==",
"version": "5.2.2",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.2.2.tgz",
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
@@ -7515,7 +7515,7 @@
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
"node": ">=14.17"
}
},
"node_modules/unbox-primitive": {

View File

@@ -30,7 +30,7 @@
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.32.7",
"typescript": "~4.5.5",
"typescript": "^5.2.2",
"vite": "^5.0.0",
"vite-plugin-pwa": "^0.19.0"
}

View File

@@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.6667 1.8335H2.33334V11.1668H13.6667V1.8335Z" stroke="#333333" stroke-width="1.33333" stroke-linejoin="round"/>
<path d="M5.33334 13.8332L8.00001 11.1665L10.6667 13.8332" stroke="#333333" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.6413 8.22084L6.52213 6.3849L8.00276 7.8332L11.3223 4.50684" stroke="#333333" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.33334 1.8335H14.6667" stroke="#333333" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 659 B

View File

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 1.3335H4.00001C3.64638 1.3335 3.30724 1.47397 3.0572 1.72402C2.80715 1.97407 2.66667 2.31321 2.66667 2.66683V13.3335C2.66667 13.6871 2.80715 14.0263 3.0572 14.2763C3.30724 14.5264 3.64638 14.6668 4.00001 14.6668H12C12.3536 14.6668 12.6928 14.5264 12.9428 14.2763C13.1929 14.0263 13.3333 13.6871 13.3333 13.3335V4.66683L10 1.3335Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.33333 1.3335V4.00016C9.33333 4.35378 9.4738 4.69292 9.72385 4.94297C9.9739 5.19302 10.313 5.3335 10.6667 5.3335H13.3333" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66666 6H5.33333" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6667 8.6665H5.33333" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6667 11.3335H5.33333" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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;
},

View File

@@ -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;
},

View File

@@ -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;
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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;

View File

@@ -3775,10 +3775,10 @@ typed-array-length@^1.0.7:
possible-typed-array-names "^1.0.0"
reflect.getprototypeof "^1.0.6"
typescript@*, "typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta", typescript@~4.5.5:
version "4.5.5"
resolved "https://registry.npmmirror.com/typescript/-/typescript-4.5.5.tgz"
integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==
typescript@*, typescript@^5.2.2, "typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta":
version "5.2.2"
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.2.2.tgz"
integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==
unbox-primitive@^1.1.0:
version "1.1.0"