<template>
|
<div>
|
<div class="toolbar">
|
<el-button v-permission="'sys:depts:create'" type="primary" @click="openAddRoot">新增一级部门</el-button>
|
</div>
|
|
<el-table
|
:data="treeData"
|
row-key="id"
|
:tree-props="{ children: 'children' }"
|
:expand-row-keys="expandedRowKeys"
|
v-loading="loading"
|
border
|
stripe
|
@expand-change="handleExpandChange"
|
>
|
<el-table-column prop="deptName" label="部门名称" min-width="280">
|
<template #default="{ row }">
|
<span>{{ row.deptName }}</span>
|
<el-tag v-if="row.status === 0" type="info" size="small" class="st-tag">禁用</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column label="操作" width="260" fixed="right">
|
<template #default="{ row }">
|
<div class="table-ops">
|
<el-button
|
v-permission="'sys:depts:create'"
|
link
|
type="primary"
|
size="small"
|
:disabled="row.level >= 5"
|
@click="openAddChild(row)"
|
>
|
添加下级
|
</el-button>
|
<el-button v-permission="'sys:depts:update'" link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
<el-button v-permission="'sys:depts:delete'" link type="primary" size="small" @click="confirmDelete(row)">删除</el-button>
|
</div>
|
</template>
|
</el-table-column>
|
</el-table>
|
|
<el-dialog v-model="dlg" :title="dlgTitle" width="480px" destroy-on-close @closed="resetDlg">
|
<el-form label-width="100px">
|
<el-form-item v-if="showParentReadonly" label="上级部门">
|
<el-input :model-value="parentDeptName" disabled />
|
</el-form-item>
|
<el-form-item v-if="showParentSelect" label="上级部门" required>
|
<el-tree-select
|
v-model="form.parentId"
|
:data="parentTreeSelectData"
|
check-strictly
|
:render-after-expand="false"
|
placeholder="请选择上级部门"
|
clearable
|
class="tree-sel"
|
/>
|
</el-form-item>
|
<el-form-item label="部门名称" required>
|
<el-input v-model="form.deptName" maxlength="128" placeholder="请输入" />
|
</el-form-item>
|
<el-form-item label="部门负责人">
|
<div class="leader-picker">
|
<el-button @click="openLeaderPicker">选择负责人</el-button>
|
<el-button v-if="selectedLeaders.length" link type="primary" @click="clearLeaders">清空</el-button>
|
<div v-if="selectedLeaders.length" class="leader-tags">
|
<el-tag
|
v-for="leader in selectedLeaders"
|
:key="leader.id"
|
closable
|
@close="removeLeader(leader.id)"
|
>
|
{{ leader.realName }}
|
</el-tag>
|
</div>
|
<div v-else class="leader-empty">未选择负责人</div>
|
</div>
|
</el-form-item>
|
<el-form-item label="部门状态" required>
|
<el-radio-group v-model="form.status">
|
<el-radio :label="1">启用</el-radio>
|
<el-radio :label="0">禁用</el-radio>
|
</el-radio-group>
|
</el-form-item>
|
</el-form>
|
<template #footer>
|
<el-button @click="dlg = false">取消</el-button>
|
<el-button
|
v-permission="mode === 'edit' ? 'sys:depts:update' : 'sys:depts:create'"
|
type="primary"
|
:loading="saving"
|
@click="submitDlg"
|
>
|
保存
|
</el-button>
|
</template>
|
</el-dialog>
|
|
<el-dialog v-model="leaderDlg" title="选择部门负责人" width="760px" destroy-on-close @closed="resetLeaderDialog">
|
<el-form class="leader-search" @submit.prevent="loadLeaderCandidates">
|
<el-form-item label="姓名">
|
<el-input v-model="leaderFilters.realName" placeholder="请输入姓名" clearable />
|
</el-form-item>
|
<el-form-item label="联系方式">
|
<el-input v-model="leaderFilters.mobile" placeholder="请输入联系方式" clearable />
|
</el-form-item>
|
<el-form-item>
|
<el-button @click="resetLeaderSearch">重置</el-button>
|
<el-button type="primary" native-type="submit">搜索</el-button>
|
</el-form-item>
|
</el-form>
|
<el-table
|
ref="leaderTableRef"
|
:data="leaderRows"
|
v-loading="leaderLoading"
|
row-key="id"
|
border
|
height="360"
|
@selection-change="onLeaderSelectionChange"
|
>
|
<el-table-column type="selection" width="48" reserve-selection />
|
<el-table-column prop="realName" label="姓名" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="mobileMasked" label="联系方式" width="140" />
|
<el-table-column prop="deptPath" label="所属部门" min-width="220" show-overflow-tooltip />
|
</el-table>
|
<div class="leader-pager">
|
<div>已选 {{ leaderSelection.length }} 人</div>
|
<el-pagination
|
background
|
layout="sizes, prev, pager, next"
|
:total="leaderTotal"
|
:page-size="leaderSize"
|
:page-sizes="[10, 20, 50, 100]"
|
v-model:current-page="leaderPage"
|
@current-change="loadLeaderCandidates"
|
@size-change="onLeaderSizeChange"
|
/>
|
</div>
|
<template #footer>
|
<el-button @click="leaderDlg = false">取消</el-button>
|
<el-button type="primary" @click="confirmLeaders">确定</el-button>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { computed, nextTick, ref } from "vue";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
import http from "../api/http";
|
import { normalizeFormData } from "../utils/formHelper";
|
|
type DeptNode = {
|
id: number;
|
uuid: string;
|
parentId?: number;
|
deptName: string;
|
sortOrder?: number;
|
status: number;
|
level: number;
|
leaderIds?: number[];
|
leaderNames?: string;
|
children?: DeptNode[];
|
};
|
|
type TreeSelNode = {
|
value: number;
|
label: string;
|
disabled?: boolean;
|
children?: TreeSelNode[];
|
};
|
|
const loading = ref(false);
|
const saving = ref(false);
|
const treeData = ref<DeptNode[]>([]);
|
const expandedRowKeys = ref<string[]>([]);
|
const dlg = ref(false);
|
type DlgMode = "root" | "child" | "edit";
|
const mode = ref<DlgMode>("root");
|
const editingId = ref<number | null>(null);
|
const editingIsRoot = ref(false);
|
const parentDeptName = ref("");
|
const childParentId = ref<number | null>(null);
|
const pendingExpandRowKey = ref<string | null>(null);
|
type StaffOption = { id: number; realName: string; deptPath: string; mobileMasked?: string };
|
const staffOptions = ref<StaffOption[]>([]);
|
const selectedLeaders = ref<StaffOption[]>([]);
|
const leaderDlg = ref(false);
|
const leaderTableRef = ref();
|
const leaderLoading = ref(false);
|
const leaderRows = ref<StaffOption[]>([]);
|
const leaderSelection = ref<StaffOption[]>([]);
|
const leaderPage = ref(1);
|
const leaderSize = ref(10);
|
const leaderTotal = ref(0);
|
const leaderFilters = ref({ realName: "", mobile: "" });
|
|
const form = ref({
|
uuid: "",
|
deptName: "",
|
status: 1,
|
parentId: undefined as number | undefined,
|
leaderIds: [] as number[],
|
});
|
|
const dlgTitle = computed(() => {
|
if (mode.value === "root") return "新增一级部门";
|
if (mode.value === "child") return "添加下级部门";
|
return "编辑部门";
|
});
|
|
const showParentReadonly = computed(() => mode.value === "child");
|
const showParentSelect = computed(() => mode.value === "edit" && !editingIsRoot.value);
|
|
const parentTreeSelectData = ref<TreeSelNode[]>([]);
|
|
function collectDescendantIds(n: DeptNode): number[] {
|
const r: number[] = [n.id];
|
for (const c of n.children || []) {
|
r.push(...collectDescendantIds(c));
|
}
|
return r;
|
}
|
|
function toTreeSelectData(nodes: DeptNode[], disabledIds: Set<number>): TreeSelNode[] {
|
return nodes.map((n) => ({
|
value: n.id,
|
label: n.deptName + (n.status === 0 ? "(禁用)" : ""),
|
disabled: disabledIds.has(n.id),
|
children: n.children?.length ? toTreeSelectData(n.children, disabledIds) : undefined,
|
}));
|
}
|
|
function toRowKey(id?: number | string | null): string | null {
|
return id == null ? null : String(id);
|
}
|
|
function collectTreeIds(nodes: DeptNode[]): string[] {
|
const ids: string[] = [];
|
for (const node of nodes) {
|
ids.push(String(node.id));
|
if (node.children?.length) {
|
ids.push(...collectTreeIds(node.children));
|
}
|
}
|
return ids;
|
}
|
|
function collectDescendantRowKeys(node: DeptNode): string[] {
|
const keys: string[] = [];
|
for (const child of node.children || []) {
|
keys.push(String(child.id));
|
keys.push(...collectDescendantRowKeys(child));
|
}
|
return keys;
|
}
|
|
function ensureExpandedRow(id?: number | string | null) {
|
const rowKey = toRowKey(id);
|
if (!rowKey || expandedRowKeys.value.includes(rowKey)) return;
|
expandedRowKeys.value = [...expandedRowKeys.value, rowKey];
|
}
|
|
async function loadTree() {
|
loading.value = true;
|
try {
|
const { data } = await http.get("/sys/dept-manage/tree");
|
treeData.value = (data.result || []) as DeptNode[];
|
const validIds = new Set(collectTreeIds(treeData.value));
|
expandedRowKeys.value = expandedRowKeys.value.filter(id => validIds.has(id));
|
if (pendingExpandRowKey.value != null && validIds.has(pendingExpandRowKey.value)) {
|
ensureExpandedRow(pendingExpandRowKey.value);
|
}
|
pendingExpandRowKey.value = null;
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "加载失败");
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function handleExpandChange(row: DeptNode, expanded: boolean) {
|
const rowKey = String(row.id);
|
if (expanded) {
|
ensureExpandedRow(rowKey);
|
return;
|
}
|
const collapsedKeys = new Set([rowKey, ...collectDescendantRowKeys(row)]);
|
expandedRowKeys.value = expandedRowKeys.value.filter(id => !collapsedKeys.has(id));
|
}
|
|
async function loadStaffOptionsByIds(ids: number[]) {
|
const missingIds = ids.filter(id => !staffOptions.value.some(s => s.id === id));
|
if (!missingIds.length) return;
|
try {
|
const { data } = await http.get("/sys/staff", { params: { page: 1, size: 1000, status: 1 } });
|
const staffList = (data.result || []) as { id: number; realName: string; deptPath?: string }[];
|
staffOptions.value = staffList.map(s => ({
|
id: s.id,
|
realName: s.realName,
|
deptPath: s.deptPath || '未分配部门',
|
}));
|
} catch (e: unknown) {
|
console.error("加载员工列表失败", e);
|
}
|
}
|
|
function syncSelectedLeadersFromIds(ids: number[], namesText?: string) {
|
const names = (namesText || "").split("、").filter(Boolean);
|
selectedLeaders.value = ids.map((id, index) => {
|
const cached = staffOptions.value.find(s => s.id === id);
|
return cached || { id, realName: names[index] || `员工${id}`, deptPath: "" };
|
});
|
}
|
|
function openAddRoot() {
|
mode.value = "root";
|
editingId.value = null;
|
editingIsRoot.value = true;
|
parentDeptName.value = "";
|
childParentId.value = null;
|
form.value = { uuid: "", deptName: "", status: 1, parentId: undefined, leaderIds: [] };
|
selectedLeaders.value = [];
|
dlg.value = true;
|
}
|
|
function openAddChild(row: DeptNode) {
|
if (row.level >= 5) return;
|
mode.value = "child";
|
editingId.value = null;
|
editingIsRoot.value = false;
|
parentDeptName.value = row.deptName;
|
childParentId.value = row.id;
|
form.value = { uuid: "", deptName: "", status: 1, parentId: row.id, leaderIds: [] };
|
selectedLeaders.value = [];
|
dlg.value = true;
|
}
|
|
async function openEdit(row: DeptNode) {
|
mode.value = "edit";
|
editingId.value = row.id;
|
const root = !row.parentId || row.parentId === 0;
|
editingIsRoot.value = root;
|
parentDeptName.value = "";
|
childParentId.value = null;
|
const leaderIds = Array.isArray(row.leaderIds) ? row.leaderIds : [];
|
await loadStaffOptionsByIds(leaderIds);
|
form.value = {
|
uuid: row.uuid,
|
deptName: row.deptName,
|
status: row.status,
|
parentId: root ? undefined : row.parentId,
|
leaderIds,
|
};
|
syncSelectedLeadersFromIds(leaderIds, row.leaderNames);
|
if (!root) {
|
const disabled = new Set(collectDescendantIds(row));
|
parentTreeSelectData.value = toTreeSelectData(treeData.value, disabled);
|
} else {
|
parentTreeSelectData.value = [];
|
}
|
dlg.value = true;
|
}
|
|
function resetDlg() {
|
editingId.value = null;
|
parentTreeSelectData.value = [];
|
}
|
|
function openLeaderPicker() {
|
leaderSelection.value = [...selectedLeaders.value];
|
leaderDlg.value = true;
|
loadLeaderCandidates();
|
}
|
|
async function loadLeaderCandidates() {
|
leaderLoading.value = true;
|
try {
|
const { data } = await http.get("/sys/staff", {
|
params: {
|
page: leaderPage.value,
|
size: leaderSize.value,
|
status: 1,
|
realName: leaderFilters.value.realName.trim() || undefined,
|
mobile: leaderFilters.value.mobile.trim() || undefined,
|
},
|
});
|
leaderRows.value = ((data.result || []) as StaffOption[]).map(s => ({
|
...s,
|
deptPath: s.deptPath || "未分配部门",
|
}));
|
leaderTotal.value = Number(data.total) || 0;
|
await nextTick();
|
syncLeaderTableSelection();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "加载员工失败");
|
} finally {
|
leaderLoading.value = false;
|
}
|
}
|
|
function syncLeaderTableSelection() {
|
const selectedIds = new Set(leaderSelection.value.map(s => s.id));
|
leaderRows.value.forEach(row => {
|
leaderTableRef.value?.toggleRowSelection(row, selectedIds.has(row.id));
|
});
|
}
|
|
function onLeaderSelectionChange(selection: StaffOption[]) {
|
const currentPageIds = new Set(leaderRows.value.map(s => s.id));
|
const kept = leaderSelection.value.filter(s => !currentPageIds.has(s.id));
|
const merged = [...kept, ...selection];
|
const seen = new Set<number>();
|
leaderSelection.value = merged.filter(item => {
|
if (seen.has(item.id)) return false;
|
seen.add(item.id);
|
return true;
|
});
|
}
|
|
function resetLeaderSearch() {
|
leaderFilters.value = { realName: "", mobile: "" };
|
leaderPage.value = 1;
|
loadLeaderCandidates();
|
}
|
|
function onLeaderSizeChange(v: number) {
|
leaderSize.value = v;
|
leaderPage.value = 1;
|
loadLeaderCandidates();
|
}
|
|
function confirmLeaders() {
|
selectedLeaders.value = [...leaderSelection.value];
|
form.value.leaderIds = selectedLeaders.value.map(s => s.id);
|
leaderDlg.value = false;
|
}
|
|
function resetLeaderDialog() {
|
leaderRows.value = [];
|
}
|
|
function removeLeader(id: number) {
|
selectedLeaders.value = selectedLeaders.value.filter(s => s.id !== id);
|
form.value.leaderIds = selectedLeaders.value.map(s => s.id);
|
}
|
|
function clearLeaders() {
|
selectedLeaders.value = [];
|
form.value.leaderIds = [];
|
}
|
|
async function submitDlg() {
|
if (!form.value.deptName.trim()) {
|
ElMessage.warning("请填写部门名称");
|
return;
|
}
|
saving.value = true;
|
try {
|
if (mode.value === "root") {
|
const payload = normalizeFormData({
|
parentId: 0,
|
deptName: form.value.deptName.trim(),
|
status: form.value.status,
|
leaderIds: form.value.leaderIds,
|
});
|
await http.post("/sys/dept-manage", payload);
|
ElMessage.success("已创建");
|
} else if (mode.value === "child") {
|
if (childParentId.value == null) {
|
ElMessage.warning("缺少上级部门");
|
return;
|
}
|
pendingExpandRowKey.value = toRowKey(childParentId.value);
|
const payload = normalizeFormData({
|
parentId: childParentId.value,
|
deptName: form.value.deptName.trim(),
|
status: form.value.status,
|
leaderIds: form.value.leaderIds,
|
});
|
await http.post("/sys/dept-manage", payload);
|
ElMessage.success("已创建");
|
} else if (editingId.value != null) {
|
const body: Record<string, unknown> = {
|
uuid: form.value.uuid,
|
deptName: form.value.deptName.trim(),
|
status: form.value.status,
|
leaderIds: form.value.leaderIds,
|
};
|
if (!editingIsRoot.value) {
|
if (form.value.parentId == null) {
|
ElMessage.warning("请选择上级部门");
|
return;
|
}
|
body.parentId = form.value.parentId;
|
}
|
const payload = normalizeFormData(body);
|
await http.put(`/sys/dept-manage/${editingId.value}`, payload);
|
ElMessage.success("已保存");
|
}
|
dlg.value = false;
|
loadTree();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "保存失败");
|
} finally {
|
saving.value = false;
|
}
|
}
|
|
function confirmDelete(row: DeptNode) {
|
ElMessageBox.confirm("删除为逻辑删除,是否继续?", "确认删除", {
|
type: "warning",
|
confirmButtonText: "删除",
|
cancelButtonText: "取消",
|
})
|
.then(async () => {
|
try {
|
await http.delete(`/sys/dept-manage/${row.id}`, { params: { uuid: row.uuid } });
|
ElMessage.success("已删除");
|
loadTree();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "删除失败");
|
}
|
})
|
.catch(() => {});
|
}
|
|
loadTree();
|
</script>
|
|
<style scoped>
|
.toolbar {
|
display: flex;
|
justify-content: flex-end;
|
margin-bottom: 12px;
|
}
|
.st-tag {
|
margin-left: 8px;
|
vertical-align: middle;
|
}
|
.tree-sel {
|
width: 100%;
|
}
|
|
.leader-picker {
|
width: 100%;
|
}
|
|
.leader-tags {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 6px;
|
margin-top: 8px;
|
}
|
|
.leader-empty {
|
color: #909399;
|
font-size: 13px;
|
margin-top: 8px;
|
}
|
|
.leader-search {
|
display: grid;
|
grid-template-columns: 1fr 1fr auto;
|
gap: 12px;
|
}
|
|
.leader-pager {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-top: 12px;
|
}
|
|
:deep(.table-ops .el-button) {
|
font-size: 14px;
|
}
|
</style>
|