Files
UserbaseFront/src/views/dept-manage/index.vue
T
chenxing 51c95dd7b9
build / build (push) Successful in 3m51s
fix(dept): 部门表格改用 iView Table,来源切换去英文并固定顺序,自定义排序改为弹框拖动
- 系统部门:自写 table 换成 iView Table(border + 表头灰底 + 行 hover),
  层级仍用名称列缩进 + 折叠;状态改为可点击的边框 Tag(已启用/已停用)
- 标题行:去掉 SYSTEM / CUSTOM 英文,「包含子组」移到来源切换左侧并整体右对齐,
  切换视图时标签位置不再左右跳动
- 自定义部门:同样换 iView Table;表格内不再拖行,改为「排序管理」按钮 + 弹框拖动,
  保存后按新顺序提交 customDeptSort;操作列保留 编辑/成员/删除
- 修正 Tag 上的 @click.native(Vue 3 已移除 .native)
2026-10-23 15:10:24 +08:00

731 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-wrapper tree-wrapper dept-manage-page">
<div v-if="!roleReady" class="dept-loading">加载中…</div>
<template v-else>
<!-- 左侧:zTree 组树(超管 / 局端;只到学校层) -->
<div v-if="showTree" class="tree-container dept-manage-tree">
<GroupTreeSearchPanel class="tree-inner" ref="refTreePanel" v-model:curTreeNode="selectedNode"
:treeApi="treeApi" :defaultSearchVisible="true" @on-change="onTreeChange" />
</div>
<!-- 右侧:部门表格(白卡由主题的 .main-container 提供) -->
<div class="main-container dept-manage-main">
<div class="panel-title">
<span class="panel-caption">部门管理</span>
<Tag v-if="currentSchoolName" color="primary" class="ml-8">{{ currentSchoolName }}</Tag>
<Select v-if="multiSchool" v-model="currentSchoolId" size="small" class="ml-8 school-select">
<Option v-for="s in schoolOptions" :key="s.id" :value="s.id">{{ s.name }}</Option>
</Select>
<!-- 来源切换:只切右侧管理视图,学校上下文共用,不改底层组织树 -->
<div class="title-tail">
<span v-if="source === 'SYSTEM'" class="inc-ctrl" title="打开后列出该校下所有层级的部门"
@click="toggleInclude">
<span class="inc-label">包含子组</span>
<i class="sw-mini" :class="{ 'is-on': includeOn }"></i>
</span>
<div class="source-tabs">
<span class="source-tab" :class="{ 'is-active': source === 'SYSTEM' }"
@click="source = 'SYSTEM'">系统部门</span>
<span class="source-tab" :class="{ 'is-active': source === 'CUSTOM' }"
@click="source = 'CUSTOM'">自定义部门</span>
</div>
</div>
</div>
<template v-if="source === 'SYSTEM'">
<Alert show-icon type="success" class="usage-note">
<b>设置说明:</b>
停用只影响「选」(新建事项 / 流程绑定时选不到),不影响「看」(已有事项展示)与「办」(在途流程照常解析);
关闭「包含子组」只展示一级部门,打开后连同下级部门一并列出。
</Alert>
<Alert v-if="!currentSchoolName" show-icon type="info" class="state-tip">
{{ showTree ? '请在左侧选择学校节点' : '当前账号没有可管理的学校' }}
</Alert>
<Alert v-else-if="!deptRows.length" show-icon type="info" class="state-tip">
该校教师组下暂无部门。部门 = 教师组的直接子节点,可前往「组,用户管理」建组。
</Alert>
<template v-else>
<div class="row-sum">{{ deptRows.length }} 个一级部门 · 共 {{ flatRows.length }} 行</div>
<Table class="dept-itable" border :columns="sysColumns" :data="flatRows"
:row-class-name="rowClassName" :loading="false" no-data-text="暂无部门">
<template #name="{ row }">
<span class="nmcell" :style="{ paddingLeft: row.level * 20 + 'px' }">
<i v-if="row.kidCount" class="tw" @click.stop="toggleRow(row.id)">{{ row.open ? '▾' : '▸' }}</i>
<i v-else class="tw blank"></i>
<span class="nm" :class="{ root: row.level === 0 }" :title="row.name">{{ row.name }}</span>
<span v-if="row.kidCount" class="cnt">{{ row.kidCount }} 个下级</span>
<span v-if="row.isStudent" class="cnt grey">学生组</span>
</span>
</template>
<template #members="{ row }">
<Tooltip v-if="row.memberCount" placement="top" max-width="360" :content="memberTip(row)">
<span class="mem-cell" @click="openMembers(row)">{{ memberText(row) }}</span>
</Tooltip>
<span v-else class="dim">无成员</span>
</template>
<template #status="{ row }">
<Tag type="border" :color="row.status === 1 ? 'error' : 'success'" class="sta-tag"
:title="row.status === 1 ? '点击启用' : '点击停用'" @click="onToggle(row.raw)">
{{ row.status === 1 ? '已停用' : '已启用' }}
</Tag>
</template>
</Table>
<div class="table-foot">共 {{ flatRows.length }} 个部门</div>
</template>
</template>
<CustomDeptPanel v-else :school-id="currentSchoolId" />
<!-- 系统部门人员:预览之外看全部,走 /api/sysuser/List?groupId= -->
<Modal v-model="memModal.visible" :title="`部门人员 · ${memModal.deptName}`" width="560"
:mask-closable="true" footer-hide>
<div class="mem-modal-body">
<div v-if="memModal.loading" class="mem-empty">加载中…</div>
<div v-else-if="!memModal.rows.length" class="mem-empty">该部门暂无直属成员</div>
<ul v-else class="mem-list">
<li v-for="u in memModal.rows" :key="u.id">
<span class="mname">{{ u.realName || u.account }}</span>
<span class="macc">{{ u.account }}</span>
</li>
</ul>
</div>
<div v-if="memModal.total > memModal.pageSize" class="mem-pager">
<Page :current="memModal.page" :total="memModal.total" :page-size="memModal.pageSize"
size="small" show-total @on-change="onMemPageChange" />
</div>
</Modal>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from "vue";
import { cloneDeep } from "lodash";
import Apis from "@/api";
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
import MyConfirm from "@/components/MyConfirm";
import CustomDeptPanel from "./CustomDeptPanel.vue";
import { Message } from "view-ui-plus";
import { useDefaultStore } from "@/store";
import { UserRoleEnum } from "@/types";
/**
* 部门管理(2026-10-23 表格版)。
* 口径:部门 = 教师组(type=7)的直接子节点,下可再挂子部门(只有教师组下才可能有多级)。
* 视角:超管 / 局端 → 左侧 zTree 树只到「学校」层,右侧是该学校的部门表格;
* 学校管理员 → 无树,直接列出本校部门(多校时顶部下拉切换)。
* 表格:层级用缩进 + 折叠表达;不提供新增 / 排序 / 行内操作,只保留启停与「包含子组」。
* 人员:getTree 已带 memberCount + memberNames(前 50),超出走 /api/sysuser/List 看全部。
*/
const TEACHER_GROUP_TYPE = 7;
const STUDENT_GROUP_TYPE = 8;
const SCHOOL_GROUP_TYPE = 2;
const store = useDefaultStore();
const isSchoolMode = computed(() => store.userRole === UserRoleEnum.School);
/** 超管 / 局端才需要左侧树 */
const showTree = computed(() => !isSchoolMode.value);
/** 角色未落定前不渲染,避免先按默认角色取数再纠正 */
const roleReady = computed(() => store.roleLoaded);
const refTreePanel = ref<InstanceType<typeof GroupTreeSearchPanel>>();
const selectedNode = ref<any>({});
/** 部门来源页签:SYSTEM=原组织树视图(行为零改动);CUSTOM=学校自建分类/部门 */
const source = ref<"SYSTEM" | "CUSTOM">("SYSTEM");
/** 全量组树:右侧部门数据一律从这里按 id 回查真实 children */
const fullTree = ref<any[]>([]);
/** 当前学校:超管由树选中,学校管理员默认取可管理学校 */
const currentSchoolId = ref<number | undefined>(undefined);
const schoolIds = computed<number[]>(() => store.userScope?.schoolGroupIds ?? []);
const multiSchool = computed(() => isSchoolMode.value && schoolIds.value.length > 1);
const schoolOptions = computed(() => schoolIds.value.map((id) => ({
id,
name: findNodeById(fullTree.value, id)?.name ?? String(id),
})));
const currentSchool = computed(() => findNodeById(fullTree.value, currentSchoolId.value));
const currentSchoolName = computed(() => currentSchool.value?.name || "");
/** 折叠状态:默认全展开,只记「被收起的 id」 */
const closedIds = ref<Set<number>>(new Set());
function toggleRow(id: number) {
const next = new Set(closedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
closedIds.value = next;
}
interface DeptRow {
id: number;
name: string;
status: number;
parentName: string;
level: number;
isStudent: boolean;
memberCount: number;
memberNames: string[];
kidCount: number;
open: boolean;
no: number;
raw: any;
children: DeptRow[];
}
/** 表格树:一级部门来自各教师组下的直接子节点,子级递归;「包含子组」关闭时不带子级 */
const deptRows = computed<DeptRow[]>(() => {
const school = currentSchool.value;
if (!school) return [];
const rows: DeptRow[] = [];
(school.children || [])
.filter((c: any) => c.type === TEACHER_GROUP_TYPE)
.forEach((t: any) => {
(t.children || []).forEach((d: any) => rows.push(toRow(d, t.name, 0)));
});
return rows;
});
function toRow(d: any, parentName: string, level: number): DeptRow {
const kids: any[] = includeOn.value ? (d.children || []) : [];
return {
id: d.id,
name: d.name,
status: d.status ?? 0,
parentName,
level,
isStudent: d.type === STUDENT_GROUP_TYPE,
memberCount: Number(d.memberCount ?? 0),
memberNames: Array.isArray(d.memberNames) ? d.memberNames : [],
kidCount: kids.length,
open: !closedIds.value.has(d.id),
no: 0,
raw: d,
children: kids.map((k) => toRow(k, d.name, level + 1)),
};
}
/** 拍平成可渲染行(按折叠状态裁剪子级),并顺序编号 */
const flatRows = computed<DeptRow[]>(() => {
const out: DeptRow[] = [];
const walk = (list: DeptRow[]) => {
list.forEach((row) => {
out.push(row);
if (row.kidCount && row.open) walk(row.children);
});
};
walk(deptRows.value);
out.forEach((row, i) => (row.no = i + 1));
return out;
});
/** iView Table 列定义:层级用「部门名称」列的缩进 + 折叠表达,不用 Table 自带树(避免样式不可控) */
const sysColumns: any[] = [
{ title: "序号", type: "index", width: 80, align: "center" },
{ title: "部门名称", slot: "name", minWidth: 300 },
{ title: "上级", key: "parentName", width: 160 },
{ title: "人员", slot: "members", minWidth: 320 },
{ title: "状态", slot: "status", width: 120, align: "center" },
];
/** 停用行整行灰化 */
function rowClassName(row: any) {
return row?.status === 1 ? "is-off" : "";
}
/** 人员列:前 3 个姓名 + 剩余数;无姓名时退回「N 人」 */
function memberText(row: DeptRow) {
if (!row.memberNames.length) return `${row.memberCount} 人`;
const head = row.memberNames.slice(0, 3).join("、");
const rest = row.memberCount - Math.min(3, row.memberNames.length);
return rest > 0 ? `${head} +${rest}` : head;
}
function memberTip(row: DeptRow) {
const list = row.memberNames.join("、");
const more = row.memberCount > row.memberNames.length ? `…(仅预览前 ${row.memberNames.length} 人)` : "";
return `共 ${row.memberCount} 人:${list}${more} 点击查看全部`;
}
/* ================= 人员弹窗(查看全部,分页) ================= */
const memModal = reactive({
visible: false,
loading: false,
deptId: 0,
deptName: "",
rows: [] as any[],
total: 0,
page: 1,
pageSize: 20,
});
function openMembers(row: DeptRow) {
Object.assign(memModal, {
visible: true, loading: true, deptId: row.id, deptName: row.name,
rows: [], total: 0, page: 1,
});
loadMembers();
}
function onMemPageChange(page: number) {
memModal.page = page;
loadMembers();
}
async function loadMembers() {
memModal.loading = true;
try {
const res: any = await Apis.SysUser.list({
params: { groupId: memModal.deptId, pageIndex: memModal.page, pageSize: memModal.pageSize },
cacheFor: null,
});
memModal.rows = res?.content ?? [];
memModal.total = Number(res?.total ?? 0);
} catch (e: any) {
console.warn(e?.message || "加载部门人员失败");
memModal.rows = [];
memModal.total = 0;
} finally {
memModal.loading = false;
}
}
/**
* 当前学校的「包含子组」:读学校节点上的 includeChildren(1=含子组 / 0=仅本级)。
* 历史数据或旧库该列为 null 时按「含子组」兜底,与升级前表现一致。
*/
const includeOn = computed(() => {
const school = currentSchool.value;
return school ? school.includeChildren !== 0 : false;
});
/* ================= 数据加载 ================= */
/** 树数据源:取全量树存起来给右侧用,树上只渲染到学校层 */
async function treeApi() {
const full: any[] = (await Apis.SysGroup.getTree()) || [];
fullTree.value = cloneDeep(full);
return pruneToSchoolLevel(full);
}
/** 剪枝:学校节点保留但不再下钻(超管视角树只到学校层) */
function pruneToSchoolLevel(list: any[]): any[] {
return (list || []).map((n: any) => {
const node: any = { ...n };
if (node.type === SCHOOL_GROUP_TYPE) {
delete node.children;
return node;
}
if (Array.isArray(node.children) && node.children.length) {
node.children = pruneToSchoolLevel(node.children);
}
return node;
});
}
function findNodeById(list: any[], id?: number | null): any {
if (id == null) return null;
for (const n of list || []) {
if (Number(n.id) === Number(id)) return n;
const hit = findNodeById(n.children || [], id);
if (hit) return hit;
}
return null;
}
/* ================= 交互 ================= */
function onTreeChange(node: any) {
selectedNode.value = node || {};
if (node?.type === SCHOOL_GROUP_TYPE) {
currentSchoolId.value = node.id;
closedIds.value = new Set();
} else if (node?.id) {
// 点到中间层(区域 / 学校组等)不作为当前学校
currentSchoolId.value = undefined;
}
}
/** 切换「包含子组」:落库到学校节点,成功后就地更新,避免整树重绘丢失选中态 */
async function toggleInclude() {
const school = currentSchool.value;
if (!school?.id) return;
const next = !includeOn.value;
try {
await Apis.SysGroup.setIncludeChildren({
pathParams: { id: school.id },
data: { includeChildren: next },
});
school.includeChildren = next ? 1 : 0;
closedIds.value = new Set();
Message.success(next ? "已包含下级部门" : "仅显示一级部门");
} catch (e: any) {
// 全局 alova 拦截器(alova-iview 的 onBizError/onHttpError)已统一 Message.error,此处只记日志避免二次弹窗;
// 本地字段只在写成功后改,失败即自然停在原值,无需回滚
console.warn(e?.message || "设置包含子组失败");
}
}
function onToggle(dept: any) {
if (!dept?.id) return;
if (dept.status === 1) {
doEnable(dept);
return;
}
MyConfirm({
title: "停用部门",
msg: `确定停用部门【${dept.name}】?`,
info: "停用只影响「选」(新建时选不到),不影响「看」和「办」(已有事项展示、在途流程照常解析)。",
async onOk() {
await doDisable(dept);
},
});
}
async function doDisable(dept: any) {
try {
await Apis.SysGroup.disable({ pathParams: { id: dept.id } });
Message.success("已停用");
await syncTreeData();
} catch (e: any) {
console.warn(e?.message || "停用失败");
}
}
async function doEnable(dept: any) {
try {
await Apis.SysGroup.enable({ pathParams: { id: dept.id } });
Message.success("已启用");
await syncTreeData();
} catch (e: any) {
console.warn(e?.message || "启用失败");
}
}
/**
* 静默同步组树数据:只替换右侧数据源,**不重建左侧 zTree**。<br>
* 启停改的是部门自身状态,而树只渲染到学校层,部门节点根本不在树上——
* 走 refreshTree 会让整棵树重绘、展开态与滚动位置一起丢失。
*/
async function syncTreeData() {
const full: any[] = (await Apis.SysGroup.getTree()) || [];
fullTree.value = cloneDeep(full);
}
/* ================= 初始化 ================= */
watch(() => store.roleLoaded, (loaded) => {
if (!loaded || !isSchoolMode.value) return;
loadSchoolScope();
}, { immediate: true });
/** 学校管理员:GetTree 返回的就是各学校子树(多校 = 多根) */
async function loadSchoolScope() {
const full: any[] = (await Apis.SysGroup.getTree()) || [];
fullTree.value = cloneDeep(full);
const ids = schoolIds.value;
if (ids.length) {
currentSchoolId.value = ids[0];
} else if (full.length) {
currentSchoolId.value = full[0]?.id;
}
}
</script>
<style scoped lang="scss">
.dept-manage-page {
/* 骨架交由主题 .tree-wrapper(flex + gap 16 + padding 16)+ .main-container(白卡);
这里只声明两卡等高,间距/留白一律不覆盖 */
align-items: stretch;
box-sizing: border-box;
/* 树:外层不滚(滚动交给内部 zTree),否则内容未超高也会因 1~2px 溢出出现滚动条 */
.dept-manage-tree {
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
.tree-inner {
flex: 1;
min-height: 0;
}
}
}
/* 右侧白卡内部纵向排布:标题行 / 说明条 / 表格 */
.dept-manage-main {
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
}
.panel-title {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
}
.panel-caption {
font-size: 14px;
font-weight: 600;
color: var(--text, #1e2635);
}
/* 标题行右侧:包含子组 + 来源切换,整体右对齐;「包含子组」放左边,切视图时 tab 位置不跳 */
.title-tail {
margin-left: auto;
display: flex;
align-items: center;
gap: 14px;
}
.school-select {
width: 180px;
}
/* 来源切换:同页两个视图,不新增菜单项 */
.source-tabs {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
background: #f2f4f9;
border-radius: 8px;
}
.source-tab {
padding: 3px 10px;
font-size: 12.5px;
line-height: 18px;
border-radius: 6px;
color: var(--text-2, #5a6478);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.source-tab.is-active {
background: var(--panel, #fff);
color: var(--brand, #577afb);
font-weight: 600;
box-shadow: 0 1px 2px rgba(30, 38, 53, .08);
}
/* 面板内顶部说明条的间距(外观完全交给 iView Alert 自身,不覆写配色) */
.usage-note {
margin-bottom: 14px;
}
/* 空态 / 无权限等临时提示 */
.state-tip {
margin-bottom: 12px;
}
.dept-loading {
flex: 1;
text-align: center;
padding-top: 80px;
color: var(--text-3, #98a1b2);
}
/* 面板头的「包含子组」开关 */
.inc-ctrl {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
font-size: 12.5px;
color: var(--text-2, #5a6478);
}
/* 「包含子组」开关:面板头与 zTree 树上共用同一套样式。
树上的控件是运行时创建的 DOM,没有 scoped 标记,只能用 :deep 命中 */
:deep(.sw-mini) {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
border-radius: 8px;
background: #d3d9e6;
transition: background .15s;
&::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(30, 38, 53, .2);
transition: left .15s;
}
&.is-on {
background: var(--brand, #577afb);
&::after {
left: 14px;
}
}
}
/* ================= 部门表格(iView Table) ================= */
.row-sum {
flex: 0 0 auto;
margin-bottom: 8px;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
}
/* 表格本体外观全部交给 iView:只补单元格内部的排版细节 */
.dept-itable {
:deep(.ivu-table-cell) {
padding-left: 12px;
padding-right: 12px;
}
:deep(.ivu-table-row.is-off td) {
background: #fcfcfd;
color: var(--text-3, #98a1b2);
}
:deep(.ivu-table-row.is-off:hover td) {
background: #f8f9fb;
}
}
.nmcell {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.nmcell .tw {
flex: 0 0 auto;
width: 14px;
text-align: center;
font-style: normal;
font-size: 9px;
color: #aeb6c2;
cursor: pointer;
}
.nmcell .tw.blank {
visibility: hidden;
}
.nmcell .nm {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nmcell .nm.root {
font-weight: 600;
color: var(--text, #1e2635);
}
.nmcell .cnt {
flex: 0 0 auto;
padding: 0 5px;
line-height: 16px;
border-radius: 4px;
background: #f1f3f7;
color: #8a94a6;
font-size: 10.5px;
}
.nmcell .cnt.grey {
background: #f6f7fa;
color: #a3adbd;
}
.mem-cell {
cursor: pointer;
color: var(--text-2, #5a6478);
border-bottom: 1px dashed transparent;
}
.mem-cell:hover {
color: var(--brand, #577afb);
border-bottom-color: #c9d4f7;
}
.dim {
color: var(--text-3, #98a1b2);
font-size: 11.5px;
}
/* 状态标签可点:hover 给一点反馈,暗示能切换 */
.sta-tag {
cursor: pointer;
}
.sta-tag:hover {
opacity: .82;
}
.table-foot {
flex: 0 0 auto;
padding: 10px 2px;
text-align: right;
font-size: 12px;
color: var(--text-3, #98a1b2);
}
/* ================= 人员弹窗 ================= */
.mem-modal-body {
max-height: 380px;
overflow: auto;
}
.mem-empty {
padding: 26px 0;
text-align: center;
font-size: 12.5px;
color: var(--text-3, #98a1b2);
}
.mem-list {
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: 10px;
height: 34px;
padding: 0 4px;
border-bottom: 1px solid #f2f5f9;
font-size: 12.5px;
&:last-child {
border-bottom: 0;
}
}
.mname {
color: var(--text, #1e2635);
}
.macc {
margin-left: auto;
color: var(--text-3, #98a1b2);
font-size: 11.5px;
}
}
.mem-pager {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
</style>