feat(dept): 部门管理改为表格,系统精简为 5 列、自定义表格化并支持拖动排序
build / build (push) Successful in 2m53s

- 系统部门:卡片网格改为表格(序号/部门名称/上级/人员/状态),层级用缩进+折叠表达;
  去掉新增、排序与操作列,只保留启停与「包含子组」
- 人员列:memberCount + memberNames 前 3 个姓名 + N,Tooltip 展示完整预览,
  点击弹窗分页查看全部(/api/sysuser/List?groupId=,不传 withSub 只看直属)
- 自定义部门:卡片改表格(序号/名称/人员/状态/操作),行拖动排序沿用 customDeptSort,
  分类列表支持拖动(customDeptCategorySort),原有新增/编辑/删除/成员/上下移保留
- DeptCard.vue、CustomDeptCard.vue 标记为已废弃(无引用,可安全删除)
This commit is contained in:
chenxing
2026-10-23 10:31:04 +08:00
parent 83a73dfcc8
commit c39c0f83d7
5 changed files with 751 additions and 60 deletions
+402 -23
View File
@@ -9,7 +9,7 @@
:treeApi="treeApi" :defaultSearchVisible="true" @on-change="onTreeChange" />
</div>
<!-- 右侧:部门卡片(白卡由主题的 .main-container 提供,不再套 iView Card) -->
<!-- 右侧:部门表格(白卡由主题的 .main-container 提供) -->
<div class="main-container dept-manage-main">
<div class="panel-title">
<span class="panel-caption">部门管理</span>
@@ -35,46 +35,112 @@
<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="!deptList.length" show-icon type="info" class="state-tip">
<Alert v-else-if="!deptRows.length" show-icon type="info" class="state-tip">
该校教师组下暂无部门。部门 = 教师组的直接子节点,可前往「组,用户管理」建组。
</Alert>
<div v-else class="dept-grid">
<DeptCard v-for="d in deptList" :key="d.id" :dept="d" :include-children="includeOn"
@toggle="onToggle" />
</div>
<template v-else>
<div class="row-sum">{{ deptRows.length }} 个一级部门 · 共 {{ flatRows.length }} 行</div>
<table class="dept-table">
<colgroup>
<col style="width: 58px" />
<col />
<col style="width: 120px" />
<col style="width: 268px" />
<col style="width: 74px" />
</colgroup>
<thead>
<tr>
<th class="ta-c">序号</th>
<th>部门名称</th>
<th>上级</th>
<th>人员</th>
<th class="ta-c">状态</th>
</tr>
</thead>
<tbody>
<tr v-for="row in flatRows" :key="row.id" :class="{ 'is-off': row.status === 1 }">
<td class="ta-c dim">{{ row.no }}</td>
<td>
<span class="nmcell" :style="{ paddingLeft: row.level * 20 + 'px' }">
<i v-if="row.kidCount" class="tw" @click="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>
</td>
<td class="dim" :title="row.parentName">{{ row.parentName }}</td>
<td>
<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>
</td>
<td class="ta-c">
<i class="sw" :class="{ 'is-on': row.status !== 1 }"
:title="row.status === 1 ? '启用' : '停用'" @click="onToggle(row.raw)"></i>
</td>
</tr>
</tbody>
</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, ref, watch } from "vue";
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 DeptCard from "./DeptCard.vue";
import CustomDeptPanel from "./CustomDeptPanel.vue";
import { Message } from "view-ui-plus";
import { useDefaultStore } from "@/store";
import { UserRoleEnum } from "@/types";
/**
* 部门管理(2026-10-21 卡片版)。
* 口径:部门 = 教师组(type=7)的直接子节点,下可再挂子部门。
* 视角:超管 / 局端 → 左侧 zTree 树只到「学校」层,学校行带「包含子组」开关;
* 部门管理(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();
@@ -103,17 +169,130 @@ const schoolOptions = computed(() => schoolIds.value.map((id) => ({
const currentSchool = computed(() => findNodeById(fullTree.value, currentSchoolId.value));
const currentSchoolName = computed(() => currentSchool.value?.name || "");
/** 部门 = 该校各教师组(type=7)的直接子节点 */
const deptList = computed<any[]>(() => {
/** 折叠状态:默认全展开,只记「被收起的 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: any[] = [];
const rows: DeptRow[] = [];
(school.children || [])
.filter((c: any) => c.type === TEACHER_GROUP_TYPE)
.forEach((t: any) => rows.push(...(t.children || [])));
.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;
});
/** 人员列:前 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 时按「含子组」兜底,与升级前表现一致。
@@ -163,6 +342,7 @@ 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;
@@ -180,6 +360,7 @@ async function toggleInclude() {
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,此处只记日志避免二次弹窗;
@@ -274,7 +455,7 @@ async function loadSchoolScope() {
}
}
/* 右侧白卡内部纵向排布:标题行 / 说明条 / 卡片网格 */
/* 右侧白卡内部纵向排布:标题行 / 说明条 / 表格 */
.dept-manage-main {
display: flex;
flex-direction: column;
@@ -328,7 +509,6 @@ async function loadSchoolScope() {
box-shadow: 0 1px 2px rgba(30, 38, 53, .08);
}
/* 右侧卡片面板撑满剩余高度(与其他页面一致),内容超出时在面板内滚动 */
/* 面板内顶部说明条的间距(外观完全交给 iView Alert 自身,不覆写配色) */
.usage-note {
margin-bottom: 14px;
@@ -390,10 +570,209 @@ async function loadSchoolScope() {
}
}
/* 部门卡片网格:同排卡片等高(与其他列表页一致) */
.dept-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(238px, 1fr));
gap: 12px;
/* ================= 部门表格 ================= */
.row-sum {
flex: 0 0 auto;
margin-bottom: 8px;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
}
.dept-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12.5px;
color: var(--text, #1e2635);
th {
height: 40px;
padding: 0 12px;
background: #f7f8fa;
border-bottom: 1px solid var(--line, #e9edf5);
color: var(--text-2, #5a6478);
font-size: 12px;
font-weight: 500;
text-align: left;
white-space: nowrap;
}
td {
height: 46px;
padding: 0 12px;
border-bottom: 1px solid var(--line, #e9edf5);
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
tbody tr:hover td {
background: #fafbfe;
}
tbody tr.is-off td {
background: #fcfcfd;
}
.ta-c {
text-align: center;
}
.dim {
color: var(--text-3, #98a1b2);
font-size: 11.5px;
}
.nmcell {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.tw {
flex: 0 0 auto;
width: 14px;
text-align: center;
font-style: normal;
font-size: 9px;
color: #aeb6c2;
cursor: pointer;
&.blank {
visibility: hidden;
}
}
.nm {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.root {
font-weight: 600;
}
}
tr.is-off .nm {
color: var(--text-3, #98a1b2);
}
.cnt {
flex: 0 0 auto;
padding: 0 5px;
line-height: 16px;
border-radius: 4px;
background: #f1f3f7;
color: #8a94a6;
font-size: 10.5px;
&.grey {
background: #f6f7fa;
color: #a3adbd;
}
}
.mem-cell {
cursor: pointer;
color: var(--text-2, #5a6478);
border-bottom: 1px dashed transparent;
&:hover {
color: var(--brand, #577afb);
border-bottom-color: #c9d4f7;
}
}
/* 胶囊开关(与小组件管理同款比例) */
.sw {
position: relative;
display: inline-block;
width: 30px;
height: 17px;
border-radius: 9px;
background: #d5d8de;
cursor: pointer;
transition: background .18s;
&::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, .2);
transition: left .18s;
}
&.is-on {
background: var(--brand, #577afb);
&::after {
left: 15px;
}
}
}
}
.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>