- 系统部门:卡片网格改为表格(序号/部门名称/上级/人员/状态),层级用缩进+折叠表达; 去掉新增、排序与操作列,只保留启停与「包含子组」 - 人员列:memberCount + memberNames 前 3 个姓名 + N,Tooltip 展示完整预览, 点击弹窗分页查看全部(/api/sysuser/List?groupId=,不传 withSub 只看直属) - 自定义部门:卡片改表格(序号/名称/人员/状态/操作),行拖动排序沿用 customDeptSort, 分类列表支持拖动(customDeptCategorySort),原有新增/编辑/删除/成员/上下移保留 - DeptCard.vue、CustomDeptCard.vue 标记为已废弃(无引用,可安全删除)
This commit is contained in:
Vendored
+4
@@ -301,6 +301,10 @@ export interface SysGroupVo {
|
||||
status?: number;
|
||||
/** 包含子组:1=含下级部门 / 0=仅本级(仅学校节点有值) */
|
||||
includeChildren?: number;
|
||||
/** 直属成员数(部门表格「人员」列;与服务端 SysGroupVo 同步,api:gen 后一样保留) */
|
||||
memberCount?: number;
|
||||
/** 直属成员姓名预览(最多 50 个),完整名单走 /api/sysuser/List?groupId= */
|
||||
memberNames?: string[];
|
||||
children?: SysGroupVo[];
|
||||
}
|
||||
export interface AppItem {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 自定义部门卡片(已被 CustomDeptPanel.vue 的表格取代,2026-10-23)。保留文件仅作参考,无任何引用,可安全删除。 -->
|
||||
<template>
|
||||
<article class="cdept-card" :class="{ 'is-off': !dept.enabled }">
|
||||
<div class="row-1">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<template v-else>
|
||||
<div class="custom-body">
|
||||
<!-- 左侧:分类是平铺列表,不是树(空 children 也不能挂成树,避免 ± 展开) -->
|
||||
<!-- 左侧:分类平铺列表(可拖动排序,↑↓ 保留兜底) -->
|
||||
<div class="cat-col">
|
||||
<div class="col-head">
|
||||
<span class="col-title">自定义分类</span>
|
||||
@@ -18,7 +18,10 @@
|
||||
</div>
|
||||
<ul v-else class="cat-list">
|
||||
<li v-for="(c, idx) in categories" :key="c.id" class="cat-item"
|
||||
:class="{ 'is-active': c.id === activeCategoryId }" @click="selectCategory(c.id)">
|
||||
:class="{ 'is-active': c.id === activeCategoryId, dragging: catDragIdx === idx }"
|
||||
draggable="true" @dragstart="onCatDragStart(idx)" @dragover.prevent="onCatDragOver(idx)"
|
||||
@drop.prevent="onCatDrop(idx)" @dragend="onCatDragEnd" @click="selectCategory(c.id)">
|
||||
<i class="grip" title="拖动排序">⠿</i>
|
||||
<span class="cat-name" :title="c.name">{{ c.name }}</span>
|
||||
<span class="cat-count">{{ c.departmentCount ?? 0 }}</span>
|
||||
<span class="cat-ops" @click.stop>
|
||||
@@ -35,10 +38,11 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:当前分类下的部门卡片 -->
|
||||
<!-- 右侧:当前分类下的部门表格(拖动行排序) -->
|
||||
<div class="dept-col">
|
||||
<div class="col-head">
|
||||
<span class="col-title">{{ activeCategoryName || '部门' }}</span>
|
||||
<span class="col-sub">{{ departments.length }} 个部门 · 拖动行可排序</span>
|
||||
<Button size="small" type="primary" :disabled="!activeCategoryId" @click="openDeptCreate">
|
||||
新增部门
|
||||
</Button>
|
||||
@@ -50,16 +54,56 @@
|
||||
<div v-else-if="!departments.length" class="col-tip">
|
||||
该分类下暂无部门。部门可先新建再配成员,成员为空时不影响保存。
|
||||
</div>
|
||||
<div v-else class="dept-grid">
|
||||
<CustomDeptCard v-for="(d, idx) in departments" :key="d.id" :dept="d"
|
||||
:can-move-up="idx > 0" :can-move-down="idx < departments.length - 1"
|
||||
@toggle="onToggleDept" @edit="openDeptEdit" @remove="removeDept"
|
||||
@members="openMembers" @move-up="moveDept(idx, -1)" @move-down="moveDept(idx, 1)" />
|
||||
</div>
|
||||
<table v-else class="dept-table">
|
||||
<colgroup>
|
||||
<col style="width: 58px" />
|
||||
<col />
|
||||
<col style="width: 268px" />
|
||||
<col style="width: 74px" />
|
||||
<col style="width: 150px" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ta-c">序号</th>
|
||||
<th>部门名称</th>
|
||||
<th>人员</th>
|
||||
<th class="ta-c">状态</th>
|
||||
<th class="ta-r">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(d, idx) in departments" :key="d.id"
|
||||
:class="{ 'is-off': !d.enabled, dragging: dragIdx === idx, 'drag-over': dragOverIdx === idx }"
|
||||
draggable="true" @dragstart="onDragStart(idx)" @dragover.prevent="onDragOver(idx)"
|
||||
@drop.prevent="onDrop(idx)" @dragend="onDragEnd">
|
||||
<td class="ta-c dim">
|
||||
<i class="grip" title="拖动排序">⠿</i>{{ idx + 1 }}
|
||||
</td>
|
||||
<td :title="d.name">{{ d.name }}</td>
|
||||
<td>
|
||||
<Tooltip v-if="Number(d.memberCount || 0)" placement="top" max-width="360"
|
||||
:content="memberTip(d)">
|
||||
<span class="mem-cell" @click="openMembers(d)">{{ memberText(d) }}</span>
|
||||
</Tooltip>
|
||||
<span v-else class="dim">无成员</span>
|
||||
</td>
|
||||
<td class="ta-c">
|
||||
<i class="sw" :class="{ 'is-on': !!d.enabled }"
|
||||
:title="d.enabled ? '停用' : '启用'" @click="onToggleDept(d)"></i>
|
||||
</td>
|
||||
<td class="ta-r">
|
||||
<a class="lnk" @click="openDeptEdit(d)">编辑</a>
|
||||
<a class="lnk" @click="openMembers(d)">成员</a>
|
||||
<a class="lnk danger" @click="removeDept(d)">删除</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="departments.length" class="table-foot">共 {{ departments.length }} 个部门</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 名称录入(分类新增/改名、部门新增/改名共用) -->
|
||||
<!-- 名称录入(分类新增/改名,部门新增/改名共用) -->
|
||||
<Modal v-model="nameModal.visible" :title="nameModal.title" :mask-closable="false" width="420">
|
||||
<Form :label-width="72">
|
||||
<FormItem :label="nameModal.kind === 'dept' ? '部门名称' : '分类名称'">
|
||||
@@ -79,7 +123,7 @@
|
||||
</Modal>
|
||||
|
||||
<CustomMemberModal v-model:visible="memberModal.visible" :dept="memberModal.dept"
|
||||
@success="loadDepartments" />
|
||||
@success="refreshAfterMemberChange" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -90,18 +134,20 @@ import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import type { CustomDeptCategoryVo, CustomDepartmentVo } from "@/api/globals";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import CustomDeptCard from "./CustomDeptCard.vue";
|
||||
import CustomMemberModal from "./CustomMemberModal.vue";
|
||||
|
||||
/**
|
||||
* CUSTOM 自定义部门管理面板(部门管理页的第二个来源页签)。
|
||||
* 口径:分类(平铺列表,容器)→ 部门(可启停 / 可配成员 / 软删)→ 成员(本校教职工,服务端排除学生组)。
|
||||
* 学校上下文由父级传入,和 SYSTEM 页签共用,切换来源不改学校。
|
||||
* 2026-10-23:部门改为表格 + 行拖动排序(沿用 customDeptSort),分类也支持拖动(沿用 customDeptCategorySort)。
|
||||
*/
|
||||
const props = defineProps<{ schoolId?: number | null }>();
|
||||
|
||||
const categories = ref<CustomDeptCategoryVo[]>([]);
|
||||
const departments = ref<CustomDepartmentVo[]>([]);
|
||||
/** deptId → 成员姓名(前 50),表格「人员」列用;完整名单在成员弹窗 */
|
||||
const memberNames = ref<Record<number, string[]>>({});
|
||||
const activeCategoryId = ref<number | null>(null);
|
||||
const loadingCategories = ref(false);
|
||||
const loadingDepartments = ref(false);
|
||||
@@ -154,6 +200,7 @@ async function loadCategories(keepActive = true) {
|
||||
async function loadDepartments() {
|
||||
if (!props.schoolId || !activeCategoryId.value) {
|
||||
departments.value = [];
|
||||
memberNames.value = {};
|
||||
return;
|
||||
}
|
||||
loadingDepartments.value = true;
|
||||
@@ -163,14 +210,54 @@ async function loadDepartments() {
|
||||
cacheFor: null,
|
||||
})) as CustomDepartmentVo[];
|
||||
departments.value = rows || [];
|
||||
loadMemberNames();
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "加载自定义部门失败");
|
||||
departments.value = [];
|
||||
memberNames.value = {};
|
||||
} finally {
|
||||
loadingDepartments.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 表格「人员」列要显示姓名,departmentCount 只有数量,这里按部门并发补一次(分类下部门数通常很少) */
|
||||
async function loadMemberNames() {
|
||||
const targets = departments.value.filter((d) => Number(d.memberCount || 0) > 0);
|
||||
if (!targets.length) {
|
||||
memberNames.value = {};
|
||||
return;
|
||||
}
|
||||
const map: Record<number, string[]> = {};
|
||||
await Promise.all(targets.map(async (d) => {
|
||||
try {
|
||||
const rows: any[] = (await Apis.CustomDept.customDeptMembers({
|
||||
pathParams: { id: d.id as number },
|
||||
cacheFor: null,
|
||||
})) || [];
|
||||
// 预览上限与系统部门对齐,避免超部门时把长名单塞进页面
|
||||
map[d.id as number] = rows.slice(0, 50).map((u) => u.name || u.account || "");
|
||||
} catch (e: any) {
|
||||
map[d.id as number] = [];
|
||||
}
|
||||
}));
|
||||
memberNames.value = map;
|
||||
}
|
||||
|
||||
function memberText(d: CustomDepartmentVo) {
|
||||
const names = memberNames.value[d.id as number] || [];
|
||||
const total = Number(d.memberCount || 0);
|
||||
if (!names.length) return `${total} 人`;
|
||||
const head = names.slice(0, 3).join("、");
|
||||
const rest = total - Math.min(3, names.length);
|
||||
return rest > 0 ? `${head} +${rest}` : head;
|
||||
}
|
||||
function memberTip(d: CustomDepartmentVo) {
|
||||
const names = memberNames.value[d.id as number] || [];
|
||||
const total = Number(d.memberCount || 0);
|
||||
const more = total > names.length ? `…(仅预览前 ${names.length} 人)` : "";
|
||||
return `共 ${total} 人:${names.join("、")}${more} 点击查看全部`;
|
||||
}
|
||||
|
||||
function selectCategory(id: number | null) {
|
||||
activeCategoryId.value = id;
|
||||
loadDepartments();
|
||||
@@ -179,11 +266,47 @@ function selectCategory(id: number | null) {
|
||||
watch(() => props.schoolId, () => {
|
||||
activeCategoryId.value = null;
|
||||
departments.value = [];
|
||||
memberNames.value = {};
|
||||
loadCategories(false);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(activeCategoryId, () => loadDepartments());
|
||||
|
||||
/* ================= 分类拖动排序 ================= */
|
||||
|
||||
const catDragIdx = ref<number | null>(null);
|
||||
|
||||
function onCatDragStart(idx: number) {
|
||||
catDragIdx.value = idx;
|
||||
}
|
||||
function onCatDragOver(idx: number) {
|
||||
if (catDragIdx.value === null || catDragIdx.value === idx) return;
|
||||
const ordered = [...categories.value];
|
||||
const [row] = ordered.splice(catDragIdx.value, 1);
|
||||
ordered.splice(idx, 0, row);
|
||||
categories.value = ordered;
|
||||
catDragIdx.value = idx;
|
||||
}
|
||||
function onCatDrop() {
|
||||
if (catDragIdx.value === null) return;
|
||||
catDragIdx.value = null;
|
||||
persistCategoryOrder();
|
||||
}
|
||||
function onCatDragEnd() {
|
||||
catDragIdx.value = null;
|
||||
}
|
||||
async function persistCategoryOrder() {
|
||||
try {
|
||||
await Apis.CustomDeptCategory.customDeptCategorySort({
|
||||
data: { schoolId: props.schoolId as number, ids: categories.value.map((c) => c.id as number) },
|
||||
cacheFor: null,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "分类排序保存失败");
|
||||
await loadCategories();
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 分类操作 ================= */
|
||||
|
||||
const hasDepartments = (c: CustomDeptCategoryVo) => Number(c.departmentCount || 0) > 0;
|
||||
@@ -228,15 +351,7 @@ async function moveCategory(idx: number, delta: number) {
|
||||
const [row] = ordered.splice(idx, 1);
|
||||
ordered.splice(target, 0, row);
|
||||
categories.value = ordered;
|
||||
try {
|
||||
await Apis.CustomDeptCategory.customDeptCategorySort({
|
||||
data: { schoolId: props.schoolId as number, ids: ordered.map((c) => c.id as number) },
|
||||
cacheFor: null,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "分类排序保存失败");
|
||||
await loadCategories();
|
||||
}
|
||||
await persistCategoryOrder();
|
||||
}
|
||||
|
||||
/* ================= 部门操作 ================= */
|
||||
@@ -369,20 +484,43 @@ async function doRemoveDept(d: CustomDepartmentVo) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 部门上移/下移:同分类内整表重排提交 */
|
||||
async function moveDept(idx: number, delta: number) {
|
||||
const target = idx + delta;
|
||||
if (target < 0 || target >= departments.value.length) return;
|
||||
/* ================= 部门拖动排序 ================= */
|
||||
|
||||
const dragIdx = ref<number | null>(null);
|
||||
const dragOverIdx = ref<number | null>(null);
|
||||
|
||||
function onDragStart(idx: number) {
|
||||
dragIdx.value = idx;
|
||||
}
|
||||
function onDragOver(idx: number) {
|
||||
if (dragIdx.value === null) return;
|
||||
dragOverIdx.value = idx;
|
||||
}
|
||||
function onDrop(idx: number) {
|
||||
if (dragIdx.value === null || dragIdx.value === idx) {
|
||||
onDragEnd();
|
||||
return;
|
||||
}
|
||||
const ordered = [...departments.value];
|
||||
const [row] = ordered.splice(idx, 1);
|
||||
ordered.splice(target, 0, row);
|
||||
const [row] = ordered.splice(dragIdx.value, 1);
|
||||
ordered.splice(idx, 0, row);
|
||||
departments.value = ordered;
|
||||
onDragEnd();
|
||||
persistDeptOrder();
|
||||
}
|
||||
function onDragEnd() {
|
||||
dragIdx.value = null;
|
||||
dragOverIdx.value = null;
|
||||
}
|
||||
|
||||
/** 拖完一次性提交整表顺序(服务端按传入 ids 落 sort_no) */
|
||||
async function persistDeptOrder() {
|
||||
try {
|
||||
await Apis.CustomDept.customDeptSort({
|
||||
data: {
|
||||
schoolId: props.schoolId as number,
|
||||
categoryId: activeCategoryId.value as number,
|
||||
ids: ordered.map((d) => d.id as number),
|
||||
ids: departments.value.map((d) => d.id as number),
|
||||
},
|
||||
cacheFor: null,
|
||||
});
|
||||
@@ -396,6 +534,11 @@ function openMembers(d: CustomDepartmentVo) {
|
||||
memberModal.dept = d;
|
||||
memberModal.visible = true;
|
||||
}
|
||||
|
||||
/** 成员弹窗改完后:人数变了,姓名预览也要重取 */
|
||||
async function refreshAfterMemberChange() {
|
||||
await Promise.all([loadCategories(), loadDepartments()]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -431,6 +574,12 @@ function openMembers(d: CustomDepartmentVo) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-sub {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.col-tip {
|
||||
padding: 26px 12px;
|
||||
text-align: center;
|
||||
@@ -474,6 +623,10 @@ function openMembers(d: CustomDepartmentVo) {
|
||||
font-size: 13px;
|
||||
color: var(--text, #1e2635);
|
||||
transition: border-color .15s, background .15s;
|
||||
|
||||
&.dragging {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.cat-item:hover {
|
||||
@@ -506,6 +659,26 @@ function openMembers(d: CustomDepartmentVo) {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* 拖动把手:默认半透明,hover 变清晰 */
|
||||
.grip {
|
||||
flex: 0 0 auto;
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: #c4c9d2;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.cat-item:hover .grip,
|
||||
.dept-table tbody tr:hover .grip {
|
||||
color: #8b96a8;
|
||||
}
|
||||
|
||||
.op {
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
@@ -531,22 +704,155 @@ function openMembers(d: CustomDepartmentVo) {
|
||||
color: #d3d9e6;
|
||||
}
|
||||
|
||||
/* 右列:部门卡片 */
|
||||
/* 右列:部门表格 */
|
||||
.dept-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.dept-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(258px, 1fr));
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
.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 {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: #fafbfe;
|
||||
}
|
||||
|
||||
tbody tr.is-off td {
|
||||
background: #fcfcfd;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
tbody tr.dragging {
|
||||
opacity: .45;
|
||||
}
|
||||
|
||||
tbody tr.drag-over td {
|
||||
border-top-color: var(--brand, #577afb);
|
||||
box-shadow: inset 0 2px 0 var(--brand, #577afb);
|
||||
}
|
||||
|
||||
.ta-c {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ta-r {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: var(--text-3, #98a1b2);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.grip {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.mem-cell {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dashed transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--brand, #577afb);
|
||||
border-bottom-color: #c9d4f7;
|
||||
}
|
||||
}
|
||||
|
||||
.lnk {
|
||||
margin-left: 10px;
|
||||
color: var(--brand, #577afb);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
|
||||
/* 胶囊开关(与系统部门表格同款比例) */
|
||||
.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 {
|
||||
padding: 10px 2px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- 系统部门卡片(已被 index.vue 的表格取代,2026-10-23)。保留文件仅作参考,无任何引用,可安全删除。 -->
|
||||
<template>
|
||||
<article class="dept-card" :class="{ 'is-off': disabled, 'has-kids': showKids }">
|
||||
<div class="row-1">
|
||||
|
||||
+402
-23
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user