feat(dept): 自定义部门成员弹窗改为穿梭框,左侧全部人员、中间箭头、右侧已有成员
build / build (push) Successful in 2m34s

- 布局对齐「分配应用用户」:左「全部人员」(本校教职工候选)+ 中间 < > 箭头 + 右「已有成员」
  (原先左侧是当前成员、右侧是候选勾选,方向与本系统其他页面相反)
- 两栏都带搜索框、全选框与计数;左栏保留服务端 keyword 检索(候选上限 200)
- 交互改为待提交模式:箭头只改本地 pending,点「保存」才先 Add 再 Remove,
  取消整体丢弃;未产生变更时保存按钮禁用,底部显示「新增 N 人 · 移出 M 人」
- 支持双击行快速移动;移出后的人员回到左侧列表,方便反悔
- 全选/半选按「当前可见行」判断,关键字筛选后不会误判
This commit is contained in:
chenxing
2026-10-26 14:16:16 +08:00
parent 4f94dbdbda
commit 73297e4b52
+295 -100
View File
@@ -1,41 +1,26 @@
<template>
<Modal v-model="innerVisible" :title="title" width="720" :mask-closable="false"
<Modal v-model="innerVisible" :title="title" width="760" :mask-closable="false"
@on-visible-change="onVisibleChange">
<div class="member-body">
<!-- 已有成员 -->
<div class="pane">
<!-- 左:全部人员(本校教职工,服务端已排除学生组与在册成员) -->
<section class="pane">
<div class="pane-head">
<span class="pane-title">当前成员</span>
<span class="pane-sub">{{ members.length }} 人</span>
<Checkbox :model-value="leftAllChecked" :indeterminate="leftIndeterminate"
@on-change="(v: any) => toggleAll(leftRows, leftChecked, !!v)" />
<span class="pane-title">全部人员</span>
<span class="pane-sub">{{ leftCheckedCount }}/{{ leftRows.length }}</span>
</div>
<div v-if="loadingMembers" class="pane-tip">加载中…</div>
<div v-else-if="!members.length" class="pane-tip">
该部门还没有成员。成员只影响「派人」,不影响部门本身的启停与展示。
</div>
<ul v-else class="pane-list">
<li v-for="u in members" :key="u.userId" class="row">
<span class="row-name">{{ u.name || u.account }}</span>
<span class="row-sub">{{ u.account }}</span>
<a class="row-op danger" @click="remove(u)">移除</a>
</li>
</ul>
</div>
<!-- 候选教职工 -->
<div class="pane">
<div class="pane-head">
<Input v-model="kw" placeholder="按账号/姓名搜索" clearable search enter-button
@on-search="loadCandidates" @on-change="onKwChange" class="kw" />
<span class="pane-sub">已选 {{ selectedIds.size }} 人</span>
<div class="pane-filter">
<Input v-model="kwLeft" size="small" placeholder="请输入搜索内容" clearable search
@on-search="loadCandidates" @on-change="onKwLeftChange" />
</div>
<div v-if="loadingCandidates" class="pane-tip">加载中…</div>
<div v-else-if="!candidates.length" class="pane-tip">
没有可添加的教职工。候选=本校教职工(系统会自动排除学生组下的人员),且尚未在该部门。
<div v-else-if="!leftRows.length" class="pane-tip">
{{ kwLeft ? '没有匹配的人员' : '没有可添加的教职工' }}
</div>
<ul v-else class="pane-list">
<li v-for="u in candidates" :key="u.userId" class="row">
<Checkbox :model-value="selectedIds.has(u.userId as number)"
@on-change="(v: any) => toggle(u.userId as number, !!v)">
<li v-for="u in leftRows" :key="u.userId" class="row" @dblclick="moveRightOne(u)">
<Checkbox :model-value="isChecked(leftChecked, u)" @on-change="(v: any) => toggleOne(leftChecked, u, !!v)">
<span class="row-name">{{ u.name || u.account }}</span>
<span class="row-sub">{{ u.account }}</span>
</Checkbox>
@@ -44,13 +29,48 @@
<div v-if="candidates.length >= CANDIDATE_PAGE_HINT" class="pane-foot">
仅显示前 {{ CANDIDATE_PAGE_HINT }} 条,可输入关键字缩小范围。
</div>
</section>
<!-- 中:加入 / 移出 -->
<div class="mid">
<Button size="small" class="mid-btn" :disabled="!rightCheckedCount" title="移出成员"
@click="moveLeft()">&lt;</Button>
<Button size="small" type="primary" class="mid-btn" :disabled="!leftCheckedCount"
title="加入成员" @click="moveRight()">&gt;</Button>
</div>
<!-- 右:已有成员 -->
<section class="pane">
<div class="pane-head">
<Checkbox :model-value="rightAllChecked" :indeterminate="rightIndeterminate"
@on-change="(v: any) => toggleAll(rightRows, rightChecked, !!v)" />
<span class="pane-title">已有成员</span>
<span class="pane-sub">{{ rightRows.length }} 人</span>
</div>
<div class="pane-filter">
<Input v-model="kwRight" size="small" placeholder="请输入搜索内容" clearable />
</div>
<div v-if="loadingMembers" class="pane-tip">加载中…</div>
<div v-else-if="!rightRows.length" class="pane-tip">
{{ kwRight ? '没有匹配的成员' : '该部门还没有成员' }}
</div>
<ul v-else class="pane-list">
<li v-for="u in rightRows" :key="u.userId" class="row" @dblclick="moveLeftOne(u)">
<Checkbox :model-value="isChecked(rightChecked, u)" @on-change="(v: any) => toggleOne(rightChecked, u, !!v)">
<span class="row-name">{{ u.name || u.account }}</span>
<span class="row-sub">{{ u.account }}</span>
</Checkbox>
</li>
</ul>
</section>
</div>
<template #footer>
<Button @click="innerVisible = false">关闭</Button>
<Button type="primary" :loading="saving" :disabled="!selectedIds.size" @click="addSelected">
添加所选
</Button>
<div class="modal-footer">
<span v-if="pendingText" class="pending-tip">{{ pendingText }}</span>
<Button @click="innerVisible = false">取消</Button>
<Button type="primary" :loading="saving" :disabled="!hasPending" @click="save">保存</Button>
</div>
</template>
</Modal>
</template>
@@ -62,9 +82,10 @@ import Apis from "@/api";
import type { CustomDepartmentMemberVo, CustomDepartmentVo } from "@/api/globals";
/**
* 自定义部门成员管理弹窗。
* 候选走 {@code /api/customdept/CandidateUsers/{id}}:服务端按「本校教职工 + 排除学生组 + 未在本部门」给列表,
* 所以这里不需要(也不能)用组维度的 GroupUsers 当候选源——那会把学生带进来(任务书 D4)。
* 自定义部门成员管理弹窗(穿梭框式)。
* 左「全部人员」=本校教职工候选(服务端按「排除学生组 + 未在本部门」给),右「已有成员」=部门在册成员。
* 中间箭头只改本地待提交状态,点「保存」才一次性提交:先 Add 再 Remove,取消则整体丢弃。
* 候选走 {@code /api/customdept/CandidateUsers/{id}},不能用组维度的 GroupUsers(会带进学生)。
*/
const props = defineProps<{ visible: boolean; dept?: CustomDepartmentVo | null }>();
const emit = defineEmits<{ "update:visible": [boolean]; success: [] }>();
@@ -78,25 +99,153 @@ const innerVisible = computed({
});
const title = computed(() => `部门成员 · ${props.dept?.name || ""}`);
const deptId = computed(() => props.dept?.id ?? null);
/** 服务端数据 */
const members = ref<CustomDepartmentMemberVo[]>([]);
const candidates = ref<CustomDepartmentMemberVo[]>([]);
const selectedIds = ref<Set<number>>(new Set());
const kw = ref("");
const loadingMembers = ref(false);
const loadingCandidates = ref(false);
const saving = ref(false);
const deptId = computed(() => props.dept?.id ?? null);
/** 待提交:新增(左→右)与移出(右→左)。点保存才落库,取消整体丢弃 */
const pendingAdd = ref<CustomDepartmentMemberVo[]>([]);
const pendingRemove = ref<Set<number>>(new Set());
const kwLeft = ref("");
const kwRight = ref("");
const leftChecked = ref<Set<number>>(new Set());
const rightChecked = ref<Set<number>>(new Set());
function matchKw(u: CustomDepartmentMemberVo, k: string) {
return (
String(u.name || "").toLowerCase().includes(k) ||
String(u.account || "").toLowerCase().includes(k)
);
}
/**
* 左列表:候选池去掉待加入的,再补上「待移出的成员」——
* 服务端候选池天然不含在册成员,若不补回来,移出后想反悔就找不到了。
*/
const leftRows = computed<CustomDepartmentMemberVo[]>(() => {
const k = kwLeft.value.trim().toLowerCase();
const hit = (u: CustomDepartmentMemberVo) => !k || matchKw(u, k);
const back = members.value.filter((m) => pendingRemove.value.has(m.userId as number) && hit(m));
const pool = candidates.value.filter(
(c) => !pendingRemove.value.has(c.userId as number) && !isPendingAdd(c.userId as number) && hit(c)
);
return [...back, ...pool];
});
/** 右列表:在册成员去掉待移出的,加上待加入的 */
const rightRows = computed<CustomDepartmentMemberVo[]>(() => {
const k = kwRight.value.trim().toLowerCase();
const hit = (u: CustomDepartmentMemberVo) => !k || matchKw(u, k);
const held = members.value.filter((m) => !pendingRemove.value.has(m.userId as number));
return [...held, ...pendingAdd.value].filter(hit);
});
function isPendingAdd(userId: number) {
return pendingAdd.value.some((u) => u.userId === userId);
}
const hasPending = computed(() => pendingAdd.value.length > 0 || pendingRemove.value.size > 0);
const pendingText = computed(() => {
const parts: string[] = [];
if (pendingAdd.value.length) parts.push(`新增 ${pendingAdd.value.length} 人`);
if (pendingRemove.value.size) parts.push(`移出 ${pendingRemove.value.size} 人`);
return parts.join(" · ");
});
/* ================= 勾选 ================= */
function isChecked(box: Set<number>, u: CustomDepartmentMemberVo) {
return box.has(u.userId as number);
}
function toggleOne(box: Set<number>, u: CustomDepartmentMemberVo, checked: boolean) {
const userId = u.userId as number;
if (checked) box.add(userId);
else box.delete(userId);
// Set 自身不是响应式依赖,换一个实例触发重算
if (box === leftChecked.value) leftChecked.value = new Set(box);
else rightChecked.value = new Set(box);
}
function moveRightOne(u: CustomDepartmentMemberVo) {
if (u.userId) moveRight([u.userId]);
}
function moveLeftOne(u: CustomDepartmentMemberVo) {
if (u.userId) moveLeft([u.userId]);
}
function toggleAll(rows: CustomDepartmentMemberVo[], box: Set<number>, checked: boolean) {
const ids = rows.map((u) => u.userId as number);
const next = new Set(box);
ids.forEach((id) => (checked ? next.add(id) : next.delete(id)));
if (box === leftChecked.value) leftChecked.value = next;
else rightChecked.value = next;
}
/* 全选 / 半选一律按「当前可见行」判断:关键字筛选后仍被勾着的行不算数 */
const leftCheckedCount = computed(
() => leftRows.value.filter((u) => leftChecked.value.has(u.userId as number)).length
);
const rightCheckedCount = computed(
() => rightRows.value.filter((u) => rightChecked.value.has(u.userId as number)).length
);
const leftAllChecked = computed(() => leftRows.value.length > 0 && leftCheckedCount.value >= leftRows.value.length);
const leftIndeterminate = computed(() => leftCheckedCount.value > 0 && !leftAllChecked.value);
const rightAllChecked = computed(() => rightRows.value.length > 0 && rightCheckedCount.value >= rightRows.value.length);
const rightIndeterminate = computed(() => rightCheckedCount.value > 0 && !rightAllChecked.value);
/* ================= 左右移动 ================= */
/** 加入:左侧勾选(或双击某行)→ 挂到待新增 */
function moveRight(userIds?: number[]) {
const ids = userIds ?? leftRows.value.filter((u) => leftChecked.value.has(u.userId as number)).map((u) => u.userId as number);
if (!ids.length) return;
const picked = leftRows.value.filter((u) => ids.includes(u.userId as number));
pendingAdd.value = [...pendingAdd.value, ...picked];
// 移出后再加入 = 撤销移出,不用重复 Add
const nextRemove = new Set(pendingRemove.value);
picked.forEach((u) => nextRemove.delete(u.userId as number));
pendingRemove.value = nextRemove;
leftChecked.value = new Set();
}
/** 移出:右侧勾选(或双击某行)→ 撤掉待新增,或记账到待移出 */
function moveLeft(userIds?: number[]) {
const ids = userIds ?? rightRows.value.filter((u) => rightChecked.value.has(u.userId as number)).map((u) => u.userId as number);
if (!ids.length) return;
const stillPending = new Set<number>();
pendingAdd.value = pendingAdd.value.filter((u) => {
if (ids.includes(u.userId as number)) {
stillPending.add(u.userId as number);
return false;
}
return true;
});
const nextRemove = new Set(pendingRemove.value);
ids.forEach((id) => {
if (!stillPending.has(id)) nextRemove.add(id);
});
pendingRemove.value = nextRemove;
rightChecked.value = new Set();
}
/* ================= 数据加载 ================= */
async function loadMembers() {
if (!deptId.value) return;
loadingMembers.value = true;
try {
members.value = (await Apis.CustomDept.customDeptMembers({
members.value = ((await Apis.CustomDept.customDeptMembers({
pathParams: { id: deptId.value },
cacheFor: null,
})) as CustomDepartmentMemberVo[] || [];
})) as CustomDepartmentMemberVo[]) || [];
} catch (e: any) {
console.warn(e?.message || "加载部门成员失败");
members.value = [];
@@ -109,11 +258,11 @@ async function loadCandidates() {
if (!deptId.value) return;
loadingCandidates.value = true;
try {
candidates.value = (await Apis.CustomDept.customDeptCandidateUsers({
candidates.value = ((await Apis.CustomDept.customDeptCandidateUsers({
pathParams: { id: deptId.value },
params: { keyword: kw.value.trim() || undefined },
params: { keyword: kwLeft.value.trim() || undefined },
cacheFor: null,
})) as CustomDepartmentMemberVo[] || [];
})) as CustomDepartmentMemberVo[]) || [];
} catch (e: any) {
console.warn(e?.message || "加载候选教职工失败");
candidates.value = [];
@@ -122,58 +271,56 @@ async function loadCandidates() {
}
}
function onKwChange() {
function onKwLeftChange() {
// clearable 清空时也当一次搜索
if (!kw.value) loadCandidates();
if (!kwLeft.value) loadCandidates();
}
function toggle(userId: number, checked: boolean) {
if (checked) selectedIds.value.add(userId);
else selectedIds.value.delete(userId);
selectedIds.value = new Set(selectedIds.value);
}
/* ================= 保存 ================= */
async function addSelected() {
if (!deptId.value || !selectedIds.value.size) return;
async function save() {
if (!deptId.value || !hasPending.value) return;
saving.value = true;
try {
if (pendingAdd.value.length) {
await Apis.CustomDept.customDeptAddMembers({
pathParams: { id: deptId.value },
data: { schoolId: props.dept?.schoolId as number, userIds: [...selectedIds.value] },
data: {
schoolId: props.dept?.schoolId as number,
userIds: pendingAdd.value.map((u) => u.userId as number),
},
cacheFor: null,
});
Message.success("已添加");
selectedIds.value = new Set();
await Promise.all([loadMembers(), loadCandidates()]);
}
if (pendingRemove.value.size) {
await Apis.CustomDept.customDeptRemoveMembers({
pathParams: { id: deptId.value },
data: { schoolId: props.dept?.schoolId as number, userIds: [...pendingRemove.value] },
cacheFor: null,
});
}
Message.success("已保存");
emit("success");
innerVisible.value = false;
} catch (e: any) {
console.warn(e?.message || "添加成员失败");
// 失败提示由全局 alova 拦截器弹出,弹窗保持打开让用户重试
console.warn(e?.message || "保存成员失败");
} finally {
saving.value = false;
}
}
async function remove(u: CustomDepartmentMemberVo) {
if (!deptId.value || !u.userId) return;
try {
await Apis.CustomDept.customDeptRemoveMembers({
pathParams: { id: deptId.value },
data: { schoolId: props.dept?.schoolId as number, userIds: [u.userId] },
cacheFor: null,
});
Message.success("已移除");
await Promise.all([loadMembers(), loadCandidates()]);
emit("success");
} catch (e: any) {
console.warn(e?.message || "移除成员失败");
}
}
/* ================= 生命周期 ================= */
function reset() {
members.value = [];
candidates.value = [];
selectedIds.value = new Set();
kw.value = "";
pendingAdd.value = [];
pendingRemove.value = new Set();
leftChecked.value = new Set();
rightChecked.value = new Set();
kwLeft.value = "";
kwRight.value = "";
}
function onVisibleChange(v: boolean) {
@@ -192,8 +339,9 @@ watch(() => props.visible, (v) => {
<style lang="scss" scoped>
.member-body {
display: flex;
gap: 12px;
height: 440px;
align-items: stretch;
gap: 8px;
height: 430px;
}
.pane {
@@ -201,14 +349,20 @@ watch(() => props.visible, (v) => {
min-width: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--line, #e2e6ef);
border-radius: 4px;
overflow: hidden;
background: #fff;
}
.pane-head {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
min-height: 30px;
gap: 8px;
height: 38px;
padding: 0 10px;
border-bottom: 1px solid #f1f4f9;
}
.pane-title {
@@ -224,19 +378,18 @@ watch(() => props.visible, (v) => {
white-space: nowrap;
}
.kw {
flex: 1;
.pane-filter {
flex: 0 0 auto;
padding: 8px 10px 6px;
}
.pane-list {
flex: 1;
min-height: 0;
margin: 0;
padding: 6px 10px;
padding: 0 6px 6px;
list-style: none;
overflow: auto;
border: 1px solid var(--line, #e9edf5);
border-radius: 4px;
}
.pane-tip {
@@ -245,46 +398,88 @@ watch(() => props.visible, (v) => {
align-items: center;
justify-content: center;
text-align: center;
padding: 0 12px;
padding: 0 16px;
font-size: 12.5px;
line-height: 1.8;
color: var(--text-3, #98a1b2);
border: 1px dashed var(--line, #e9edf5);
border-radius: 4px;
background: #fbfcfd;
}
.pane-foot {
padding-top: 6px;
flex: 0 0 auto;
padding: 6px 10px 8px;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
border-top: 1px solid #f6f8fb;
}
.row {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 4px;
border-bottom: 1px solid #f5f5f5;
height: 30px;
padding: 0 4px;
border-radius: 3px;
font-size: 13px;
cursor: pointer;
user-select: none;
&:hover {
background: #f5f7fb;
}
:deep(.ivu-checkbox-wrapper) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
margin-right: 0;
}
:deep(.ivu-checkbox) {
flex: 0 0 auto;
margin-right: 6px;
}
}
.row-name {
font-weight: 500;
color: #272829;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-sub {
flex: 0 0 auto;
margin-left: 6px;
font-size: 12px;
color: #999;
color: #9aa1ad;
}
.row-op {
margin-left: auto;
font-size: 12px;
cursor: pointer;
/* 中间箭头列 */
.mid {
flex: 0 0 36px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.row-op.danger {
color: #e54545;
.mid-btn {
width: 32px;
padding: 0;
font-weight: 600;
}
.modal-footer {
display: flex;
align-items: center;
gap: 8px;
}
.pending-tip {
margin-right: auto;
font-size: 12px;
color: var(--text-3, #98a1b2);
}
</style>