web- 用户选择组件
This commit is contained in:
@@ -62,17 +62,6 @@ export const userApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户分页列表
|
|
||||||
* @param pageParam 分页参数
|
|
||||||
* @param filter 过滤条件
|
|
||||||
* @returns Promise<ResultDomain<SysUser>>
|
|
||||||
*/
|
|
||||||
async getUserPage(pageParam: PageParam, filter: SysUser): Promise<ResultDomain<SysUser>> {
|
|
||||||
const response = await api.post<SysUser>('/users/page', { pageParam, filter });
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建用户
|
* 创建用户
|
||||||
* @param user 用户信息
|
* @param user 用户信息
|
||||||
|
|||||||
213
schoolNewsWeb/src/components/user/README.md
Normal file
213
schoolNewsWeb/src/components/user/README.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# 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 请求中
|
||||||
|
|
||||||
781
schoolNewsWeb/src/components/user/UserSelect.vue
Normal file
781
schoolNewsWeb/src/components/user/UserSelect.vue
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
8
schoolNewsWeb/src/components/user/index.ts
Normal file
8
schoolNewsWeb/src/components/user/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @description 用户相关组件
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-22
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as UserSelect } from './UserSelect.vue';
|
||||||
|
|
||||||
@@ -317,9 +317,13 @@
|
|||||||
:title="selectorMode === 'add' ? '添加人员' : '删除人员'"
|
:title="selectorMode === 'add' ? '添加人员' : '删除人员'"
|
||||||
:left-title="selectorMode === 'add' ? '可添加人员' : '当前人员'"
|
:left-title="selectorMode === 'add' ? '可添加人员' : '当前人员'"
|
||||||
:right-title="selectorMode === 'add' ? '待添加人员' : '待删除人员'"
|
:right-title="selectorMode === 'add' ? '待添加人员' : '待删除人员'"
|
||||||
:available-users="availableUsers"
|
:available-users="selectorMode === 'remove' ? availableUsers : []"
|
||||||
:initial-target-users="[]"
|
:initial-target-users="[]"
|
||||||
:loading="saving"
|
:loading="saving"
|
||||||
|
:use-pagination="selectorMode === 'add'"
|
||||||
|
:fetch-api="selectorMode === 'add' ? userApi.getUserPage : undefined"
|
||||||
|
:filter-params="userFilterParams"
|
||||||
|
:page-size="20"
|
||||||
@confirm="handleUserSelectConfirm"
|
@confirm="handleUserSelectConfirm"
|
||||||
@cancel="closeSelectorModal"
|
@cancel="closeSelectorModal"
|
||||||
/>
|
/>
|
||||||
@@ -378,10 +382,10 @@ const deleting = ref(false);
|
|||||||
// 人员管理相关
|
// 人员管理相关
|
||||||
const managingTask = ref<LearningTask | null>(null);
|
const managingTask = ref<LearningTask | null>(null);
|
||||||
const selectorMode = ref<'add' | 'remove'>('add');
|
const selectorMode = ref<'add' | 'remove'>('add');
|
||||||
const allUsers = ref<UserVO[]>([]);
|
|
||||||
const currentUsers = ref<UserVO[]>([]);
|
const currentUsers = ref<UserVO[]>([]);
|
||||||
const availableUsers = ref<UserVO[]>([]);
|
const availableUsers = ref<UserVO[]>([]);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const userFilterParams = ref<any>({});
|
||||||
|
|
||||||
// 计算显示的页码
|
// 计算显示的页码
|
||||||
const displayPages = computed(() => {
|
const displayPages = computed(() => {
|
||||||
@@ -519,23 +523,24 @@ async function handleUpdateUser(task: LearningTask) {
|
|||||||
managingTask.value = task;
|
managingTask.value = task;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 加载所有用户
|
|
||||||
const usersRes = await userApi.getUserList({});
|
|
||||||
if (usersRes.success && usersRes.dataList) {
|
|
||||||
allUsers.value = usersRes.dataList;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载任务详情(包含已分配的用户)
|
// 加载任务详情(包含已分配的用户)
|
||||||
const taskRes = await learningTaskApi.getTaskById(task.taskID!);
|
const taskRes = await learningTaskApi.getTaskById(task.taskID!);
|
||||||
if (taskRes.success && taskRes.data) {
|
if (taskRes.success && taskRes.data) {
|
||||||
const taskUsers = taskRes.data.taskUsers || [];
|
const taskUsers = taskRes.data.taskUsers || [];
|
||||||
const assignedUserIds = taskUsers.map(tu => tu.userID!);
|
const assignedUserIds = taskUsers.map(tu => tu.userID!);
|
||||||
|
|
||||||
// 当前已分配的用户
|
// 加载用户详情(批量查询)
|
||||||
currentUsers.value = allUsers.value.filter(user =>
|
if (assignedUserIds.length > 0) {
|
||||||
|
const usersRes = await userApi.getUserList({});
|
||||||
|
if (usersRes.success && usersRes.dataList) {
|
||||||
|
currentUsers.value = usersRes.dataList.filter(user =>
|
||||||
assignedUserIds.includes(user.id!)
|
assignedUserIds.includes(user.id!)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
currentUsers.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showUserList.value = true;
|
showUserList.value = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -555,11 +560,8 @@ function closeUserList() {
|
|||||||
function showAddUserSelector() {
|
function showAddUserSelector() {
|
||||||
selectorMode.value = 'add';
|
selectorMode.value = 'add';
|
||||||
|
|
||||||
// 左侧显示未分配的用户
|
// 设置过滤参数(可以过滤掉已分配的用户,但在组件内部会自动过滤)
|
||||||
const currentUserIds = currentUsers.value.map(u => u.id!);
|
userFilterParams.value = {};
|
||||||
availableUsers.value = allUsers.value.filter(user =>
|
|
||||||
!currentUserIds.includes(user.id!)
|
|
||||||
);
|
|
||||||
|
|
||||||
showUserSelector.value = true;
|
showUserSelector.value = true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user