fix(dept): 修包含子组下表格空白,状态改回胶囊开关,人员前 10 可点,自定义左栏轻量化
build / build (push) Successful in 3m41s

- 修 bug:DeptRow 的 children 改名为 _kids —— iView Table 见行上有 children 会切树形数据模式,
  与自画的缩进/折叠打架,表现为开启「包含子组」时整表空白(关闭时 children 为空数组才恢复正常)
- 关闭「包含子组」时仍保留「N 个下级」灰标,只收起子行,层级信息不再丢
- 状态列从边框 Tag 改回胶囊开关(与「包含子组」同一套样式)
- 人员列:最多平铺 10 个姓名 + N,全部列完(≤10 人)时不可点击;
  姓名区可截断、+N 固定不收缩;列宽改为 序号70 / 名称236 / 上级130 / 人员自适应 / 状态90
- 自定义部门左栏改轻量分类列表(无边框卡片、选中浅底 + 左侧细蓝条),
  分类的排序 / 改名 / 删除 / 新增收进「分类管理」弹框:行内改名(回车或失焦保存,Esc 取消)、
  有部门时删除禁用、底部新增分类;保存排序沿用 customDeptCategorySort
- 自定义部门表格同步:状态胶囊开关、人员前 10 规则、列宽与系统部门一致
This commit is contained in:
chenxing
2026-10-26 11:46:44 +08:00
parent 51c95dd7b9
commit 4f94dbdbda
2 changed files with 519 additions and 263 deletions
+442 -227
View File
@@ -6,34 +6,21 @@
<template v-else>
<div class="custom-body">
<!-- 左侧:分类平铺列表(容器,可拖动排序,↑↓ 保留兜底) -->
<!-- 左侧:分类轻量列表(只管选中,排序/改名/删除都在「分类管理」弹框里) -->
<div class="cat-col">
<div class="col-head">
<span class="col-title">自定义分类</span>
<Button size="small" type="primary" @click="openCategoryCreate">新增分类</Button>
<Button size="small" @click="openCatManage">分类管理</Button>
</div>
<div v-if="loadingCategories" class="col-tip">加载中…</div>
<div v-else-if="!categories.length" class="col-tip">
暂无自定义分类。分类只是管理容器,不能启停也不能配成员,请先新建一个。
暂无自定义分类。点右上「分类管理」新建一个,分类只是管理容器,不能启停也不能配成员。
</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, 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>
<li v-for="c in categories" :key="c.id" class="cat"
:class="{ 'is-active': c.id === activeCategoryId }" @click="selectCategory(c.id)">
<span class="cat-name" :title="c.name">{{ c.name }}</span>
<span class="cat-count">{{ c.departmentCount ?? 0 }}</span>
<span class="cat-ops" @click.stop>
<i class="op" :class="{ 'is-disabled': idx === 0 }" title="上移"
@click="moveCategory(idx, -1)">↑</i>
<i class="op" :class="{ 'is-disabled': idx === categories.length - 1 }" title="下移"
@click="moveCategory(idx, 1)">↓</i>
<i class="op" title="改名" @click="openCategoryRename(c)">✎</i>
<i class="op" :class="{ 'is-disabled': hasDepartments(c) }"
:title="hasDepartments(c) ? '分类下还有部门,不能删除' : '删除分类'"
@click="removeCategory(c)">×</i>
</span>
</li>
</ul>
</div>
@@ -51,7 +38,7 @@
</Button>
</div>
<Alert v-if="!activeCategoryId" show-icon type="warning" class="state-tip">
请先在左侧选择一个分类,或新建一个分类。
请先在左侧选择一个分类,或点「分类管理」新建一个分类。
</Alert>
<div v-else-if="loadingDepartments" class="col-tip">加载中…</div>
<Table v-else class="dept-itable" border :columns="deptColumns" :data="departments"
@@ -59,15 +46,18 @@
<template #members="{ row }">
<Tooltip v-if="Number(row.memberCount || 0)" placement="top" max-width="360"
:content="memberTip(row)">
<span class="mem-cell" @click="openMembers(row)">{{ memberText(row) }}</span>
<span class="mem" :class="memberClickable(row) ? 'mem-cell' : 'mem-plain'"
@click="onMemberClick(row)">
<span class="names">{{ memberHead(row) }}</span>
<span v-if="memberRest(row)" class="more">+{{ memberRest(row) }}</span>
</span>
</Tooltip>
<span v-else class="dim">无成员</span>
</template>
<template #status="{ row }">
<Tag type="border" :color="row.enabled ? 'success' : 'error'" class="sta-tag"
:title="row.enabled ? '点击停用' : '点击启用'" @click="onToggleDept(row)">
{{ row.enabled ? '已启用' : '已停用' }}
</Tag>
<i class="sw-mini" :class="{ 'is-on': row.enabled }"
:title="row.enabled ? '点击停用' : '点击启用'"
@click="onToggleDept(row)"></i>
</template>
<template #actions="{ row }">
<a class="lnk" @click="openDeptEdit(row)">编辑</a>
@@ -79,7 +69,49 @@
</div>
</div>
<!-- 排序弹框:拖动左侧手柄调整顺序,保存后按新顺序落库 -->
<!-- 分类管理:拖动排序 + 行内改名 / 删除 / 新增 -->
<Modal v-model="catManage.visible" title="分类管理" width="560" :mask-closable="false">
<Alert type="info" show-icon class="sort-tip">
拖动左侧手柄调整分类顺序;分类下还有部门时不能删除。
</Alert>
<ul class="sort-list">
<li v-for="(c, idx) in catManage.list" :key="c.id ?? 'new'" class="sort-item"
:class="{ dragging: catSortIdx === idx, 'drag-over': catSortOverIdx === idx, editing: catManage.editIdx === idx }"
:draggable="catManage.editIdx !== idx" @dragstart="onCatSortStart(idx)"
@dragover.prevent="onCatSortOver(idx)" @drop.prevent="onCatSortDrop"
@dragend="onCatSortEnd">
<i class="grip">⠿</i>
<span class="sno">{{ idx + 1 }}</span>
<Input v-if="catManage.editIdx === idx" v-model="catManage.editName" size="small"
class="snm-input" maxlength="32" @on-enter="commitCatRename"
@on-blur="commitCatRename" @on-keydown="onCatKeydown" />
<span v-else class="snm" :title="c.name">{{ c.name }}</span>
<span class="cCnt">{{ c.departmentCount ?? 0 }} 个部门</span>
<span class="row-ops">
<a class="lnk" @click.stop="startCatRename(idx)">改名</a>
<a class="lnk danger" :class="{ 'is-disabled': hasDepartments(c) }"
:title="hasDepartments(c) ? '分类下还有部门,请先移走或删除这些部门' : '删除分类'"
@click.stop="removeCategory(c)">删除</a>
</span>
</li>
<li v-if="catManage.adding" class="sort-item editing" draggable="false">
<i class="grip">⠿</i>
<span class="sno">+</span>
<Input v-model="catManage.newName" size="small" class="snm-input" maxlength="32"
placeholder="输入分类名称后回车" @on-enter="commitCatAdd" @on-blur="commitCatAdd"
@on-keydown="onCatKeydown" />
</li>
</ul>
<template #footer>
<div class="modal-footer">
<Button class="footer-left" @click="startCatAdd">+ 新增分类</Button>
<Button @click="catManage.visible = false">取消</Button>
<Button type="primary" :loading="catManage.saving" @click="saveCatOrder">保存排序</Button>
</div>
</template>
</Modal>
<!-- 部门排序弹框:拖动左侧手柄调整顺序,保存后按新顺序落库 -->
<Modal v-model="sortVisible" :title="`部门排序 · ${activeCategoryName}`" width="520" :mask-closable="false">
<Alert type="info" show-icon class="sort-tip">拖动左侧手柄调整顺序,保存后生效。</Alert>
<ul class="sort-list">
@@ -98,14 +130,14 @@
</template>
</Modal>
<!-- 名称录入(分类新增/改名,部门新增/改名共用) -->
<!-- 名称录入(部门新增 / 编辑) -->
<Modal v-model="nameModal.visible" :title="nameModal.title" :mask-closable="false" width="420">
<Form :label-width="72">
<FormItem :label="nameModal.kind === 'dept' ? '部门名称' : '分类名称'">
<FormItem label="部门名称">
<Input v-model="nameModal.name" :placeholder="nameModal.placeholder"
maxlength="64" show-word-limit />
</FormItem>
<FormItem v-if="nameModal.kind === 'dept' && nameModal.isEdit" label="归属分类">
<FormItem v-if="nameModal.isEdit" label="归属分类">
<Select v-model="nameModal.categoryId" placeholder="移动到本校其他分类">
<Option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</Option>
</Select>
@@ -124,7 +156,7 @@
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from "vue";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import Apis from "@/api";
import type { CustomDeptCategoryVo, CustomDepartmentVo } from "@/api/globals";
@@ -135,8 +167,8 @@ import CustomMemberModal from "./CustomMemberModal.vue";
* CUSTOM 自定义部门管理面板(部门管理页的第二个来源页签)。
* 口径:分类(平铺列表,容器)→ 部门(可启停 / 可配成员 / 软删)→ 成员(本校教职工,服务端排除学生组)。
* 学校上下文由父级传入,和 SYSTEM 页签共用,切换来源不改学校。
* 2026-10-23:部门改为 iView Table;排序移入「排序管理」弹框拖动(沿用 customDeptSort),
* 分类仍是列表内拖动(沿用 customDeptCategorySort)。
* 2026-10-26:左栏改轻量列表(只管选中),分类的排序 / 改名 / 删除 / 新增全部收进「分类管理」弹框;
* 部门仍是 iView Table,排序走「排序管理」弹框拖动(沿用 customDeptSort)。
*/
const props = defineProps<{ schoolId?: number | null }>();
@@ -153,10 +185,10 @@ const activeCategoryName = computed(() => activeCategory.value?.name || "");
/** iView Table 列定义(排序不在表格里做,行不拖拽) */
const deptColumns: any[] = [
{ title: "序号", type: "index", width: 80, align: "center" },
{ title: "部门名称", key: "name", minWidth: 220 },
{ title: "人员", slot: "members", minWidth: 300 },
{ title: "状态", slot: "status", width: 120, align: "center" },
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "部门名称", key: "name", width: 236 },
{ title: "人员", slot: "members", minWidth: 200 },
{ title: "状态", slot: "status", width: 90, align: "center" },
{ title: "操作", slot: "actions", width: 160, align: "right" },
];
function rowClassName(row: any) {
@@ -166,7 +198,6 @@ function rowClassName(row: any) {
const nameModal = reactive({
visible: false,
saving: false,
kind: "category" as "category" | "dept",
isEdit: false,
id: null as number | null,
name: "",
@@ -251,19 +282,33 @@ async function loadMemberNames() {
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;
/** 人员列:最多平铺 10 个姓名,超出用「+N」;已全部显示(≤10 人)时不可点 */
const MEMBER_PREVIEW = 10;
function memberNamesOf(d: CustomDepartmentVo) {
return memberNames.value[d.id as number] || [];
}
function memberHead(d: CustomDepartmentVo) {
const names = memberNamesOf(d);
// 姓名预览还没回来时先退回人数,避免出现空单元格
if (!names.length) return `${Number(d.memberCount || 0)} 人`;
return names.slice(0, MEMBER_PREVIEW).join("、");
}
function memberRest(d: CustomDepartmentVo) {
if (!memberNamesOf(d).length) return 0;
return Math.max(0, Number(d.memberCount || 0) - Math.min(MEMBER_PREVIEW, memberNamesOf(d).length));
}
function memberClickable(d: CustomDepartmentVo) {
return Number(d.memberCount || 0) > MEMBER_PREVIEW;
}
function onMemberClick(d: CustomDepartmentVo) {
if (memberClickable(d)) openMembers(d);
}
function memberTip(d: CustomDepartmentVo) {
const names = memberNames.value[d.id as number] || [];
const names = memberNamesOf(d);
const total = Number(d.memberCount || 0);
if (!names.length) return `共 ${total} 人 点击查看全部`;
const more = total > names.length ? `…(仅预览前 ${names.length} 人)` : "";
return `共 ${total} 人:${names.join("、")}${more} 点击查看全部`;
return `共 ${total} 人:${names.join("、")}${more}${memberClickable(d) ? " 点击查看全部" : ""}`;
}
function selectCategory(id: number | null) {
@@ -280,6 +325,195 @@ watch(() => props.schoolId, () => {
watch(activeCategoryId, () => loadDepartments());
/* ================= 分类管理弹框(拖动排序 + 改名 / 删除 / 新增) ================= */
const catManage = reactive({
visible: false,
saving: false,
list: [] as CustomDeptCategoryVo[],
/** 行内改名:>=0 改第 idx 行 */
editIdx: null as number | null,
editName: "",
/** 底部「+ 新增分类」:末尾插一行输入框 */
adding: false,
newName: "",
});
/** 改名 / 新增共用一个提交锁:回车会让输入框失焦,失焦又会再触发一次提交 */
let catCommitLock = false;
const catSortIdx = ref<number | null>(null);
const catSortOverIdx = ref<number | null>(null);
function openCatManage() {
catManage.list = categories.value.map((c) => ({ ...c }));
catManage.editIdx = null;
catManage.editName = "";
catManage.adding = false;
catManage.newName = "";
catSortIdx.value = null;
catSortOverIdx.value = null;
catManage.visible = true;
}
/** 弹框里的增删改后,把服务端最新分类同步进弹框与左栏 */
async function reloadCatAll() {
await loadCategories();
catManage.list = categories.value.map((c) => ({ ...c }));
}
const hasDepartments = (c: CustomDeptCategoryVo) => Number(c.departmentCount || 0) > 0;
function startCatRename(idx: number) {
catManage.adding = false;
catManage.editIdx = idx;
catManage.editName = catManage.list[idx]?.name || "";
nextTick(() => {
const el = document.querySelector(".snm-input input") as HTMLInputElement | null;
el?.focus();
el?.select();
});
}
function cancelCatEdit() {
catManage.editIdx = null;
catManage.editName = "";
}
function onCatKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
cancelCatEdit();
catManage.adding = false;
catManage.newName = "";
}
}
async function commitCatRename() {
if (catCommitLock) return;
const idx = catManage.editIdx;
if (idx === null) return;
const row = catManage.list[idx];
const name = catManage.editName.trim();
if (!row) { cancelCatEdit(); return; }
if (!name || name === row.name) { cancelCatEdit(); return; }
catCommitLock = true;
try {
await Apis.CustomDeptCategory.customDeptCategoryEdit({
pathParams: { id: row.id as number },
data: { schoolId: props.schoolId as number, name },
cacheFor: null,
});
row.name = name;
// 左栏同步改名,不必整表重取
const hit = categories.value.find((c) => c.id === row.id);
if (hit) hit.name = name;
Message.success("已保存");
} catch (e: any) {
// 同名校验等后端拒绝由全局拦截器弹出,输入框保持打开让用户改
console.warn(e?.message || "分类改名失败");
return;
} finally {
catCommitLock = false;
}
// 期间若用户已经点到下一行改名,不要把它刚打开的编辑态关掉
if (catManage.editIdx === idx) cancelCatEdit();
}
function startCatAdd() {
catManage.editIdx = null;
catManage.adding = true;
catManage.newName = "";
nextTick(() => {
const el = document.querySelector(".snm-input input") as HTMLInputElement | null;
el?.focus();
});
}
async function commitCatAdd() {
if (catCommitLock) return;
if (!catManage.adding) return;
const name = catManage.newName.trim();
if (!name) { catManage.adding = false; catManage.newName = ""; return; }
catCommitLock = true;
try {
await Apis.CustomDeptCategory.customDeptCategoryAdd({
data: { schoolId: props.schoolId as number, name },
cacheFor: null,
});
Message.success("已新增分类");
catManage.adding = false;
catManage.newName = "";
await reloadCatAll();
} catch (e: any) {
console.warn(e?.message || "新增分类失败");
} finally {
catCommitLock = false;
}
}
function removeCategory(c: CustomDeptCategoryVo) {
if (hasDepartments(c)) {
// 服务端同样会拒绝;前端先挡一道并说清原因,避免用户点了才知道
Message.warning(`分类「${c.name}」下还有 ${c.departmentCount} 个部门,请先移动或删除这些部门`);
return;
}
MyConfirm({
title: "删除分类",
msg: `确定删除分类【${c.name}】?`,
info: "删除为软删除:分类下的部门必须已先移走,历史引用仍按原名称解析。",
async onOk() {
await Apis.CustomDeptCategory.customDeptCategoryDelete({ pathParams: { id: c.id as number } });
Message.success("已删除");
await reloadCatAll();
// 删的是当前分类时 loadCategories 会自动切到第一个,这里补一次部门列表
await loadDepartments();
},
});
}
function onCatSortStart(idx: number) {
if (catManage.editIdx !== null || catManage.adding) return;
catSortIdx.value = idx;
}
function onCatSortOver(idx: number) {
if (catSortIdx.value === null || catSortIdx.value === idx) return;
catSortOverIdx.value = idx;
const ordered = [...catManage.list];
const [row] = ordered.splice(catSortIdx.value, 1);
ordered.splice(idx, 0, row);
catManage.list = ordered;
catSortIdx.value = idx;
}
function onCatSortDrop() {
catSortIdx.value = null;
}
function onCatSortEnd() {
catSortIdx.value = null;
catSortOverIdx.value = null;
}
async function saveCatOrder() {
if (!catManage.list.length) {
catManage.visible = false;
return;
}
catManage.saving = true;
try {
await Apis.CustomDeptCategory.customDeptCategorySort({
data: { schoolId: props.schoolId as number, ids: catManage.list.map((c) => c.id as number) },
cacheFor: null,
});
// 左栏直接采用弹框里的顺序,避免重取丢选中态
categories.value = catManage.list.map((c) => ({ ...c }));
catManage.visible = false;
Message.success("分类顺序已保存");
} catch (e: any) {
console.warn(e?.message || "分类排序保存失败");
await loadCategories();
} finally {
catManage.saving = false;
}
}
/* ================= 部门排序(弹框拖动) ================= */
const sortVisible = ref(false);
@@ -343,100 +577,18 @@ async function saveSort() {
}
}
/* ================= 分类拖动排序 ================= */
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;
function openCategoryCreate() {
Object.assign(nameModal, {
kind: "category", isEdit: false, id: null, name: "", categoryId: null,
title: "新增分类", placeholder: "如:审核部门 / 事项部门", visible: true,
});
}
function openCategoryRename(c: CustomDeptCategoryVo) {
Object.assign(nameModal, {
kind: "category", isEdit: true, id: c.id ?? null, name: c.name || "", categoryId: null,
title: `重命名分类「${c.name}」`, placeholder: "分类名称", visible: true,
});
}
function removeCategory(c: CustomDeptCategoryVo) {
if (hasDepartments(c)) {
// 服务端同样会拒绝;前端先挡一道并说清原因,避免用户点了才知道
Message.warning(`分类「${c.name}」下还有 ${c.departmentCount} 个部门,请先移动或删除这些部门`);
return;
}
MyConfirm({
title: "删除分类",
msg: `确定删除分类【${c.name}】?`,
info: "删除为软删除:分类下的部门必须已先移走,历史引用仍按原名称解析。",
async onOk() {
await Apis.CustomDeptCategory.customDeptCategoryDelete({ pathParams: { id: c.id as number } });
Message.success("已删除");
await loadCategories();
},
});
}
/** 上移/下移:整表重排后一次性提交(服务端按传入顺序落 sort_no) */
async function moveCategory(idx: number, delta: number) {
const target = idx + delta;
if (target < 0 || target >= categories.value.length) return;
const ordered = [...categories.value];
const [row] = ordered.splice(idx, 1);
ordered.splice(target, 0, row);
categories.value = ordered;
await persistCategoryOrder();
}
/* ================= 部门操作 ================= */
function openDeptCreate() {
Object.assign(nameModal, {
kind: "dept", isEdit: false, id: null, name: "", categoryId: activeCategoryId.value,
isEdit: false, id: null, name: "", categoryId: activeCategoryId.value,
title: "新增部门", placeholder: "如:校长室 / 教务处", visible: true,
});
}
function openDeptEdit(d: CustomDepartmentVo) {
Object.assign(nameModal, {
kind: "dept", isEdit: true, id: d.id ?? null, name: d.name || "", categoryId: d.categoryId ?? null,
isEdit: true, id: d.id ?? null, name: d.name || "", categoryId: d.categoryId ?? null,
title: `编辑部门「${d.name}」`, placeholder: "部门名称", visible: true,
});
}
@@ -449,24 +601,6 @@ async function submitNameModal() {
}
nameModal.saving = true;
try {
if (nameModal.kind === "category") {
if (nameModal.isEdit) {
await Apis.CustomDeptCategory.customDeptCategoryEdit({
pathParams: { id: nameModal.id as number },
data: { schoolId: props.schoolId as number, name },
cacheFor: null,
});
} else {
await Apis.CustomDeptCategory.customDeptCategoryAdd({
data: { schoolId: props.schoolId as number, name },
cacheFor: null,
});
}
nameModal.visible = false;
Message.success("已保存");
await loadCategories();
return;
}
if (nameModal.isEdit) {
await Apis.CustomDept.customDeptEdit({
pathParams: { id: nameModal.id as number },
@@ -609,6 +743,7 @@ async function refreshAfterMemberChange() {
padding: 26px 12px;
text-align: center;
font-size: 12.5px;
line-height: 1.8;
color: var(--text-3, #98a1b2);
border: 1px dashed var(--line, #e9edf5);
border-radius: var(--r, 10px);
@@ -619,12 +754,14 @@ async function refreshAfterMemberChange() {
margin-bottom: 12px;
}
/* 左列:分类平铺列表 */
/* ================= 左列:分类轻量列表 ================= */
.cat-col {
flex: 0 0 260px;
flex: 0 0 220px;
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--line, #eef1f7);
padding-right: 12px;
}
.cat-list {
@@ -636,32 +773,40 @@ async function refreshAfterMemberChange() {
overflow: auto;
}
.cat-item {
/* 参考应用管理的左栏分类:无边框卡片感,选中靠浅底 + 左侧细蓝条 */
.cat {
position: relative;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
margin-bottom: 6px;
border: 1px solid var(--line, #e9edf5);
border-radius: var(--r, 8px);
cursor: pointer;
gap: 8px;
height: 38px;
padding: 0 10px 0 12px;
border-radius: 6px;
font-size: 13px;
color: var(--text, #1e2635);
transition: border-color .15s, background .15s;
&.dragging {
opacity: .5;
}
cursor: pointer;
transition: background .15s, color .15s;
}
.cat-item:hover {
border-color: #dbe2fb;
background: #f7f9ff;
.cat:hover {
background: #f5f7fb;
}
.cat-item.is-active {
border-color: var(--brand, #577afb);
background: #f2f5ff;
.cat.is-active {
background: var(--brand-weak, #f0f4ff);
color: var(--brand, #577afb);
font-weight: 500;
}
.cat.is-active::before {
content: "";
position: absolute;
left: 0;
top: 9px;
bottom: 9px;
width: 2px;
border-radius: 0 2px 2px 0;
background: var(--brand, #577afb);
}
.cat-name {
@@ -678,58 +823,12 @@ async function refreshAfterMemberChange() {
color: var(--text-3, #98a1b2);
}
.cat-ops {
flex: 0 0 auto;
display: inline-flex;
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,
.sort-item:hover .grip {
color: #8b96a8;
}
.op {
font-style: normal;
font-size: 12px;
line-height: 18px;
width: 18px;
text-align: center;
border-radius: 4px;
color: var(--text-2, #5a6478);
}
.op:hover {
background: #e8ecfa;
.cat.is-active .cat-count {
color: var(--brand, #577afb);
opacity: .8;
}
.op.is-disabled {
color: #d3d9e6;
cursor: not-allowed;
}
.op.is-disabled:hover {
background: transparent;
color: #d3d9e6;
}
/* 右列:部门表格 */
/* ================= 右列:部门表格 ================= */
.dept-col {
flex: 1;
min-width: 0;
@@ -755,15 +854,42 @@ async function refreshAfterMemberChange() {
}
}
.mem-cell {
cursor: pointer;
/* 人员列:姓名区可截断,「+N」固定不收缩 */
.mem {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
max-width: 100%;
color: var(--text-2, #5a6478);
border-bottom: 1px dashed transparent;
}
.mem-cell:hover {
.mem .names {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mem .more {
flex: 0 0 auto;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
}
.mem-cell {
cursor: pointer;
}
.mem-cell:hover .names {
color: var(--brand, #577afb);
border-bottom-color: #c9d4f7;
text-decoration-line: underline;
text-decoration-style: dashed;
text-underline-offset: 3px;
}
.mem-plain {
cursor: default;
}
.dim {
@@ -771,12 +897,37 @@ async function refreshAfterMemberChange() {
font-size: 11.5px;
}
.sta-tag {
cursor: pointer;
}
/* 状态开关:与系统部门同一套胶囊 */
:deep(.sw-mini) {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
border-radius: 8px;
background: #d3d9e6;
transition: background .15s;
vertical-align: middle;
.sta-tag:hover {
opacity: .82;
&::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;
}
}
}
.lnk {
@@ -805,7 +956,7 @@ async function refreshAfterMemberChange() {
color: var(--text-3, #98a1b2);
}
/* ================= 排序弹框 ================= */
/* ================= 排序 / 分类管理弹框 ================= */
.sort-tip {
margin-bottom: 10px;
}
@@ -843,6 +994,30 @@ async function refreshAfterMemberChange() {
border-color: var(--brand, #577afb);
box-shadow: 0 0 0 2px rgba(87, 122, 251, .16);
}
&.editing {
border-color: var(--brand, #577afb);
background: #fbfcff;
cursor: default;
}
}
.grip {
flex: 0 0 auto;
font-style: normal;
font-size: 13px;
line-height: 1;
color: #c4c9d2;
cursor: grab;
user-select: none;
&:active {
cursor: grabbing;
}
}
.sort-item:hover .grip {
color: #8b96a8;
}
.sort-item .sno {
@@ -866,4 +1041,44 @@ async function refreshAfterMemberChange() {
text-overflow: ellipsis;
white-space: nowrap;
}
.snm-input {
flex: 1;
min-width: 0;
}
.sort-item .cCnt {
flex: 0 0 auto;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
}
.sort-item .row-ops {
flex: 0 0 auto;
display: flex;
gap: 10px;
}
.sort-item .row-ops .lnk {
margin-left: 0;
}
.sort-item .row-ops .lnk.is-disabled {
color: #b3bccb;
cursor: not-allowed;
&:hover {
text-decoration: none;
}
}
.modal-footer {
display: flex;
align-items: center;
gap: 8px;
}
.footer-left {
margin-right: auto;
}
</style>
+77 -36
View File
@@ -53,7 +53,7 @@
: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-if="row._kids.length" 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>
@@ -62,15 +62,18 @@
</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>
<span class="mem" :class="memberClickable(row) ? 'mem-cell' : 'mem-plain'"
@click="onMemberClick(row)">
<span class="names">{{ memberHead(row) }}</span>
<span v-if="memberRest(row)" class="more">+{{ memberRest(row) }}</span>
</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>
<i class="sw-mini" :class="{ 'is-on': row.status !== 1 }"
:title="row.status === 1 ? '点击启用' : '点击停用'"
@click="onToggle(row.raw)"></i>
</template>
</Table>
<div class="table-foot">共 {{ flatRows.length }} 个部门</div>
@@ -173,7 +176,12 @@ interface DeptRow {
open: boolean;
no: number;
raw: any;
children: DeptRow[];
/**
* 子级只给自己用。
* ⚠️ 绝不能叫 children:iView Table 发现行上有 children 就切「树形数据模式」(还要求 row-key),
* 会和这里自画的缩进 + 折叠打架,表现是整表空白(关掉包含子组后 children 为空数组才恢复)。
*/
_kids: DeptRow[];
}
/** 表格树:一级部门来自各教师组下的直接子节点,子级递归;「包含子组」关闭时不带子级 */
@@ -190,7 +198,7 @@ const deptRows = computed<DeptRow[]>(() => {
});
function toRow(d: any, parentName: string, level: number): DeptRow {
const kids: any[] = includeOn.value ? (d.children || []) : [];
const kids: any[] = d.children || [];
return {
id: d.id,
name: d.name,
@@ -200,11 +208,12 @@ function toRow(d: any, parentName: string, level: number): DeptRow {
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)),
_kids: includeOn.value ? kids.map((k) => toRow(k, d.name, level + 1)) : [],
};
}
@@ -214,7 +223,7 @@ const flatRows = computed<DeptRow[]>(() => {
const walk = (list: DeptRow[]) => {
list.forEach((row) => {
out.push(row);
if (row.kidCount && row.open) walk(row.children);
if (row.open && row._kids.length) walk(row._kids);
});
};
walk(deptRows.value);
@@ -224,28 +233,40 @@ const flatRows = computed<DeptRow[]>(() => {
/** 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" },
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "部门名称", slot: "name", width: 236 },
{ title: "上级", key: "parentName", width: 130 },
{ title: "人员", slot: "members", minWidth: 200 },
{ title: "状态", slot: "status", width: 90, align: "center" },
];
/** 停用行整行灰化 */
function rowClassName(row: any) {
return row?.status === 1 ? "is-off" : "";
}
/** 人员列:前 3 个姓名 + 剩余数;无姓名时退回「N 人」 */
function memberText(row: DeptRow) {
/** 人员列:最多平铺 10 个姓名,超出用「+N」提示;已全部显示(≤10 人)时不可点 */
const MEMBER_PREVIEW = 10;
function memberHead(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;
return row.memberNames.slice(0, MEMBER_PREVIEW).join("、");
}
function memberRest(row: DeptRow) {
if (!row.memberNames.length) return 0;
return Math.max(0, row.memberCount - Math.min(MEMBER_PREVIEW, row.memberNames.length));
}
/** 只有列不完时才值得点开弹窗(全在表格里看完了,弹窗没有增量信息) */
function memberClickable(row: DeptRow) {
return row.memberCount > MEMBER_PREVIEW;
}
function onMemberClick(row: DeptRow) {
if (memberClickable(row)) openMembers(row);
}
function memberTip(row: DeptRow) {
if (!row.memberNames.length) return `共 ${row.memberCount} 人 点击查看全部`;
const list = row.memberNames.join("、");
const more = row.memberCount > row.memberNames.length ? `…(仅预览前 ${row.memberNames.length} 人)` : "";
return `共 ${row.memberCount} 人:${list}${more} 点击查看全部`;
return `共 ${row.memberCount} 人:${list}${more}${memberClickable(row) ? " 点击查看全部" : ""}`;
}
/* ================= 人员弹窗(查看全部,分页) ================= */
@@ -550,6 +571,7 @@ async function loadSchoolScope() {
border-radius: 8px;
background: #d3d9e6;
transition: background .15s;
vertical-align: middle;
&::after {
content: '';
@@ -645,15 +667,43 @@ async function loadSchoolScope() {
color: #a3adbd;
}
.mem-cell {
cursor: pointer;
/* 人员列:姓名区可截断,「+N」固定不收缩(名字太长时也得看得出还有没列完的人) */
.mem {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
max-width: 100%;
color: var(--text-2, #5a6478);
border-bottom: 1px dashed transparent;
}
.mem-cell:hover {
.mem .names {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mem .more {
flex: 0 0 auto;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
}
/* 只有超过 10 人才可点(全部列完再弹窗没有增量信息) */
.mem-cell {
cursor: pointer;
}
.mem-cell:hover .names {
color: var(--brand, #577afb);
border-bottom-color: #c9d4f7;
text-decoration-line: underline;
text-decoration-style: dashed;
text-underline-offset: 3px;
}
.mem-plain {
cursor: default;
}
.dim {
@@ -661,16 +711,7 @@ async function loadSchoolScope() {
font-size: 11.5px;
}
/* 状态标签可点:hover 给一点反馈,暗示能切换 */
.sta-tag {
cursor: pointer;
}
.sta-tag:hover {
opacity: .82;
}
/* 状态开关:复用「包含子组」那套胶囊;表格里要跟文字垂直居中 */
.table-foot {
flex: 0 0 auto;
padding: 10px 2px;