From 73297e4b5254b3fb0cf98d65780f0070330bdf90 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 26 Oct 2026 14:16:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(dept):=20=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E9=83=A8=E9=97=A8=E6=88=90=E5=91=98=E5=BC=B9=E7=AA=97=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E7=A9=BF=E6=A2=AD=E6=A1=86=EF=BC=8C=E5=B7=A6=E4=BE=A7?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E4=BA=BA=E5=91=98=E3=80=81=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E7=AE=AD=E5=A4=B4=E3=80=81=E5=8F=B3=E4=BE=A7=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E6=88=90=E5=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 布局对齐「分配应用用户」:左「全部人员」(本校教职工候选)+ 中间 < > 箭头 + 右「已有成员」 (原先左侧是当前成员、右侧是候选勾选,方向与本系统其他页面相反) - 两栏都带搜索框、全选框与计数;左栏保留服务端 keyword 检索(候选上限 200) - 交互改为待提交模式:箭头只改本地 pending,点「保存」才先 Add 再 Remove, 取消整体丢弃;未产生变更时保存按钮禁用,底部显示「新增 N 人 · 移出 M 人」 - 支持双击行快速移动;移出后的人员回到左侧列表,方便反悔 - 全选/半选按「当前可见行」判断,关键字筛选后不会误判 --- src/views/dept-manage/CustomMemberModal.vue | 403 +++++++++++++++----- 1 file changed, 299 insertions(+), 104 deletions(-) diff --git a/src/views/dept-manage/CustomMemberModal.vue b/src/views/dept-manage/CustomMemberModal.vue index f5bf7af..e8233f7 100644 --- a/src/views/dept-manage/CustomMemberModal.vue +++ b/src/views/dept-manage/CustomMemberModal.vue @@ -1,41 +1,26 @@ @@ -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([]); const candidates = ref([]); -const selectedIds = ref>(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([]); +const pendingRemove = ref>(new Set()); + +const kwLeft = ref(""); +const kwRight = ref(""); +const leftChecked = ref>(new Set()); +const rightChecked = ref>(new Set()); + +function matchKw(u: CustomDepartmentMemberVo, k: string) { + return ( + String(u.name || "").toLowerCase().includes(k) || + String(u.account || "").toLowerCase().includes(k) + ); +} + +/** + * 左列表:候选池去掉待加入的,再补上「待移出的成员」—— + * 服务端候选池天然不含在册成员,若不补回来,移出后想反悔就找不到了。 + */ +const leftRows = computed(() => { + 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(() => { + 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, u: CustomDepartmentMemberVo) { + return box.has(u.userId as number); +} + +function toggleOne(box: Set, 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, 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(); + 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 { - await Apis.CustomDept.customDeptAddMembers({ - pathParams: { id: deptId.value }, - data: { schoolId: props.dept?.schoolId as number, userIds: [...selectedIds.value] }, - cacheFor: null, - }); - Message.success("已添加"); - selectedIds.value = new Set(); - await Promise.all([loadMembers(), loadCandidates()]); + if (pendingAdd.value.length) { + await Apis.CustomDept.customDeptAddMembers({ + pathParams: { id: deptId.value }, + data: { + schoolId: props.dept?.schoolId as number, + userIds: pendingAdd.value.map((u) => u.userId as number), + }, + cacheFor: null, + }); + } + 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) => {