新增部门子级查询功能,更新相关API接口和前端组件,优化部门树形结构展示,支持父子部门关系的处理

This commit is contained in:
Chuck1sn
2025-06-19 15:29:22 +08:00
parent fa580a5dd4
commit ea86342a0f
12 changed files with 749 additions and 162 deletions

View File

@@ -4,6 +4,7 @@ import com.zl.mjga.dto.PageRequestDto;
import com.zl.mjga.dto.PageResponseDto;
import com.zl.mjga.dto.department.DepartmentQueryDto;
import com.zl.mjga.dto.department.DepartmentRespDto;
import com.zl.mjga.dto.department.DepartmentWithParentDto;
import com.zl.mjga.repository.DepartmentRepository;
import com.zl.mjga.service.DepartmentService;
import java.util.List;
@@ -37,6 +38,12 @@ public class DepartmentController {
return departmentService.queryAvailableParentDepartmentsBy(id);
}
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).READ_DEPARTMENT_PERMISSION)")
@GetMapping("/query-sub")
List<DepartmentWithParentDto> querySubDepartment(@RequestParam(required = false) Long id) {
return departmentService.queryDepartmentAndSubsBy(id);
}
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_DEPARTMENT_PERMISSION)")
@DeleteMapping()
void deleteDepartment(@RequestParam Long id) {

View File

@@ -37,7 +37,8 @@ public class DepartmentRepository extends DepartmentDao {
DEPARTMENT.PARENT_ID,
DEPARTMENT.NAME.cast(VARCHAR))
.from(DEPARTMENT)
.where(DEPARTMENT.ID.eq(id))
.where(id == null ? noCondition() : DEPARTMENT.ID.eq(id))
.and(DEPARTMENT.PARENT_ID.isNull())
.unionAll(
select(
DEPARTMENT.ID,

View File

@@ -3,13 +3,17 @@ import { http, HttpResponse } from "msw";
export default [
http.get("/department/page-query", () => {
const generateDepartment = () => ({
id: faker.number.int({ min: 1, max: 100 }),
name: faker.company.name(),
parentId: faker.number.int({ min: 1, max: 100 }),
isBound: faker.datatype.boolean(),
parentName: faker.company.name(),
});
const generateDepartment = () => {
// 20% 的概率生成 parentId 为 null 的数据
const hasParent = faker.datatype.boolean(0.8);
return {
id: faker.number.int({ min: 1, max: 100 }),
name: faker.company.name(),
parentId: hasParent ? faker.number.int({ min: 1, max: 100 }) : null,
isBound: faker.datatype.boolean(),
parentName: hasParent ? faker.company.name() : null,
};
};
const mockData = {
data: faker.helpers.multiple(generateDepartment, { count: 10 }),
total: 30,
@@ -17,21 +21,41 @@ export default [
return HttpResponse.json(mockData);
}),
http.get("/department/query-available", () => {
const generateDepartment = () => ({
id: faker.number.int({ min: 1, max: 30 }),
name: faker.company.name(),
parentId: faker.number.int({ min: 1, max: 30 }),
parentName: faker.company.name(),
});
const generateDepartment = () => {
// 20% 的概率生成 parentId 为 null 的数据
const hasParent = faker.datatype.boolean(0.8);
return {
id: faker.number.int({ min: 1, max: 30 }),
name: faker.company.name(),
parentId: hasParent ? faker.number.int({ min: 1, max: 30 }) : null,
parentName: hasParent ? faker.company.name() : null,
};
};
const mockData = faker.helpers.multiple(generateDepartment, { count: 30 });
return HttpResponse.json(mockData);
}),
http.post("/department", () => {
console.log("Captured department upsert");
return HttpResponse.json();
}),
http.get("/department/query-sub", ({ request }) => {
const generateDepartment = () => {
// 20% 的概率生成 parentId 为 null 的数据
const hasParent = faker.datatype.boolean(0.8);
return {
id: faker.number.int({ min: 1, max: 30 }),
name: faker.company.name(),
parentId: hasParent ? faker.number.int({ min: 1, max: 30 }) : null,
parentName: hasParent ? faker.company.name() : null,
};
};
const mockData = faker.helpers.multiple(generateDepartment, {
count: 30,
});
return HttpResponse.json(mockData);
}),
http.delete("/department", () => {
console.log("Captured department delete");
return HttpResponse.json();

View File

@@ -1077,6 +1077,40 @@
}
}
},
"/department/query-sub": {
"get": {
"tags": [
"department-controller"
],
"operationId": "querySubDepartment",
"parameters": [
{
"name": "id",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DepartmentWithParentDto"
}
}
}
}
}
}
}
},
"/department/query-available": {
"get": {
"tags": [
@@ -1977,6 +2011,35 @@
}
}
},
"DepartmentWithParentDto": {
"required": [
"id",
"name",
"parentId",
"parentName",
"path"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"parentId": {
"type": "integer",
"format": "int64"
},
"parentName": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"DepartmentQueryDto": {
"type": "object",
"properties": {

View File

@@ -532,6 +532,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/department/query-sub": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["querySubDepartment"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/department/query-available": {
parameters: {
query?: never;
@@ -884,6 +900,15 @@ export interface components {
total?: number;
data?: components["schemas"]["PermissionRespDto"][];
};
DepartmentWithParentDto: {
/** Format: int64 */
id: number;
name: string;
/** Format: int64 */
parentId: number;
parentName: string;
path: string;
};
DepartmentQueryDto: {
/** Format: int64 */
userId?: number;
@@ -1826,6 +1851,28 @@ export interface operations {
};
};
};
querySubDepartment: {
parameters: {
query?: {
id?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DepartmentWithParentDto"][];
};
};
};
};
queryAvailableParentDepartmentsBy: {
parameters: {
query?: {

View File

@@ -0,0 +1,87 @@
<template>
<div class="department-tree">
<!-- 空状态 -->
<div v-if="!departmentTree || departmentTree.length === 0" class="flex flex-col items-center justify-center py-8">
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"></path>
</svg>
<p class="mt-2 text-sm text-gray-500">暂无部门数据</p>
</div>
<div v-else>
<!-- 操作按钮区域 -->
<div class="flex justify-end mb-3 gap-2">
<TableButton variant="secondary" size="xs" @click="toggleAllNodes" :title="isAllExpanded ? '收起所有部门' : '展开所有部门'">
<template #icon>
<svg v-if="isAllExpanded" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</template>
{{ isAllExpanded ? '收起所有' : '展开所有' }}
</TableButton>
</div>
<!-- 树形结构内容区域 -->
<div class="max-h-[500px] overflow-y-auto p-2 bg-gray-50 border border-gray-200 rounded shadow-inner">
<TreeNodeComponent v-for="node in departmentTree" :key="node.id" :node="node" :expand-all="expandAll"
@add-child="handleAddChild" @edit="handleEditNode" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import TableButton from "@/components/tables/TableButton.vue";
import type { DepartmentTreeNode } from "@/composables/department/useDepartmentQuery";
import { computed, defineEmits, defineProps, ref, watch } from "vue";
import TreeNodeComponent from "./TreeNodeComponent.vue";
// 定义组件属性
const props = defineProps<{
departmentTree: DepartmentTreeNode[];
}>();
// 定义事件
const emit = defineEmits<{
(e: "add-child", node: DepartmentTreeNode): void;
(e: "edit", node: DepartmentTreeNode): void;
}>();
// 控制所有节点展开/折叠状态
const expandAll = ref<boolean>(true);
// 通过计算属性提供展开状态
const isAllExpanded = computed(() => expandAll.value);
// 监听树数据变化,当数据改变时重置展开状态
watch(
() => props.departmentTree,
() => {
// 当树数据变化时,默认展开所有节点
expandAll.value = true;
},
{ deep: true },
);
// 切换所有节点的展开/折叠状态
const toggleAllNodes = () => {
expandAll.value = !expandAll.value;
};
// 处理添加子部门
const handleAddChild = (node: DepartmentTreeNode) => {
emit("add-child", node);
};
// 处理编辑节点
const handleEditNode = (node: DepartmentTreeNode) => {
emit("edit", node);
};
</script>

View File

@@ -0,0 +1,129 @@
<template>
<div class="relative mb-0.5">
<div class="flex items-center py-1.5 px-2 cursor-pointer hover:bg-gray-100 rounded transition-colors group"
@click="toggleExpand">
<!-- 折叠/展开图标 -->
<span class="mr-1.5 text-gray-500 cursor-pointer flex-shrink-0" v-if="node.children && node.children.length > 0">
<svg v-if="localExpanded" class="w-4 h-4 transform transition-transform duration-200" fill="none"
stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
<svg v-else class="w-4 h-4 transform transition-transform duration-200" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</span>
<span class="mr-1.5 text-gray-500 invisible flex-shrink-0" v-else>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</span>
<!-- 部门名称 -->
<span class="text-sm font-medium text-gray-800 flex-grow cursor-pointer truncate" :title="node.name">
{{ node.name }}
</span>
<!-- 操作按钮 -->
<div class="ml-auto hidden group-hover:flex flex-shrink-0">
<button @click.stop="$emit('add-child', node)"
class="text-gray-500 hover:text-blue-600 focus:outline-none transition-colors p-0.5" title="添加子部门">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</button>
<button @click.stop="$emit('edit', node)"
class="text-gray-500 hover:text-blue-600 focus:outline-none transition-colors ml-1 p-0.5" title="编辑部门">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z">
</path>
</svg>
</button>
</div>
</div>
<transition enter-active-class="transition-[height,opacity] duration-300 ease-in-out overflow-hidden"
leave-active-class="transition-[height,opacity] duration-300 ease-in-out overflow-hidden" @enter="expandEnter"
@after-enter="expandAfterEnter" @leave="expandLeave">
<div v-if="localExpanded && node.children && node.children.length > 0"
class="pl-4 border-l border-gray-200 ml-2 overflow-hidden">
<TreeNodeComponent v-for="childNode in node.children" :key="childNode.id" :node="childNode"
:expand-all="expandAll" @add-child="$emit('add-child', $event)" @edit="$emit('edit', $event)" />
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import type { DepartmentTreeNode } from "@/composables/department/useDepartmentQuery";
import { defineEmits, defineProps, ref, watch } from "vue";
// 定义组件属性
const props = defineProps<{
node: DepartmentTreeNode;
expandAll: boolean;
}>();
// 定义事件
defineEmits<{
(e: "add-child", node: DepartmentTreeNode): void;
(e: "edit", node: DepartmentTreeNode): void;
}>();
// 本地展开状态,默认展开
const localExpanded = ref(true);
// 监听expandAll属性变化
watch(
() => props.expandAll,
(newVal) => {
localExpanded.value = newVal;
},
{ immediate: true },
);
// 切换展开状态
const toggleExpand = () => {
localExpanded.value = !localExpanded.value;
};
// 展开动画处理函数
const expandEnter = (element: Element) => {
const el = element as HTMLElement;
el.style.height = "0";
el.style.opacity = "0";
};
const expandAfterEnter = (element: Element) => {
const el = element as HTMLElement;
el.style.height = "";
el.style.opacity = "";
};
const expandLeave = (element: Element) => {
const el = element as HTMLElement;
el.style.height = `${el.scrollHeight}px`;
// 强制回流
void el.offsetHeight;
el.style.height = "0";
el.style.opacity = "0";
};
</script>
<style scoped>
.department-tree-node {
position: relative;
margin-bottom: 2px;
}
.expand-enter-active,
.expand-leave-active {
transition: height 0.3s ease, opacity 0.3s ease;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,7 @@
import DepartmentTree from "./DepartmentTree.vue";
export { DepartmentTree };
export default {
DepartmentTree,
};

View File

@@ -1,14 +1,20 @@
import client from "@/api/client";
export const useDepartmentDelete = () => {
const deleteDepartment = async (departmentId: number) => {
await client.DELETE("/department", {
params: {
query: {
id: departmentId,
const deleteDepartment = async (departmentId: number): Promise<boolean> => {
try {
const response = await client.DELETE("/department", {
params: {
query: {
id: departmentId,
},
},
},
});
});
return response.response.ok;
} catch (error) {
console.error("删除部门失败:", error);
return false;
}
};
return {
deleteDepartment,

View File

@@ -1,51 +1,151 @@
import client from "@/api/client";
import type { components } from "@/api/types/schema";
import { ref } from "vue";
import type { components } from "../../api/types/schema";
export const useDepartmentQuery = () => {
const total = ref<number>(0);
const departments = ref<components["schemas"]["DepartmentRespDto"][]>();
// 定义部门树节点类型
export interface DepartmentTreeNode {
id: number;
name: string;
parentId: number | null;
children?: DepartmentTreeNode[];
}
export function useDepartmentQuery() {
// 部门列表数据
const departments = ref<components["schemas"]["DepartmentRespDto"][]>([]);
// 可用的部门列表(用于选择上级部门)
const availableDepartments = ref<components["schemas"]["Department"][]>();
// 部门树形结构数据
const departmentTree = ref<DepartmentTreeNode[]>([]);
// 总记录数
const total = ref<number>(0);
const fetchAvailableDepartments = async (id?: number) => {
const { data } = await client.GET("/department/query-available", {
params: {
query: {
id,
},
},
});
availableDepartments.value = data ?? [];
};
// 获取部门列表数据
const fetchDepartmentWith = async (
param: {
params: {
name?: string;
enable?: boolean;
userId?: number;
bindState?: "ALL" | "BIND" | "UNBIND";
},
} = {},
page = 1,
size = 10,
pageSize = 10,
) => {
const { data } = await client.GET("/department/page-query", {
params: {
query: {
pageRequestDto: {
page,
size,
try {
const response = await client.GET("/department/page-query", {
params: {
query: {
pageRequestDto: {
page,
size: pageSize,
},
departmentQueryDto: {
name: params.name || "",
},
},
departmentQueryDto: param,
},
},
});
total.value = !data || !data.total ? 0 : data.total;
departments.value = data?.data ?? [];
});
if (response.data) {
departments.value = response.data.data || [];
total.value = response.data.total || 0;
}
return response.data?.data || [];
} catch (error) {
console.error("获取部门列表失败:", error);
return [];
}
};
// 获取可用部门列表(用于选择上级部门)
const fetchAvailableDepartments = async (excludeId?: number) => {
try {
const response = await client.GET("/department/query-available", {
params: {
query: {
id: excludeId,
},
},
});
if (response.data) {
availableDepartments.value = response.data;
}
return response.data || [];
} catch (error) {
console.error("获取可用部门列表失败:", error);
return [];
}
};
// 构建部门树形结构
const buildDepartmentTree = (
departments: components["schemas"]["DepartmentWithParentDto"][],
): DepartmentTreeNode[] => {
// 创建映射表,方便快速查找
const map = new Map<number, DepartmentTreeNode>();
const roots: DepartmentTreeNode[] = [];
// 先将所有部门添加到映射表中
for (const dept of departments) {
if (dept.id) {
map.set(dept.id, {
id: dept.id,
name: dept.name || "",
parentId: dept.parentId || null,
children: [],
});
}
}
// 构建树形结构
for (const dept of departments) {
if (!dept.id) continue;
const node = map.get(dept.id);
if (!node) continue;
if (dept.parentId && map.has(dept.parentId)) {
// 如果有父节点将当前节点添加到父节点的children中
const parent = map.get(dept.parentId);
parent?.children?.push(node);
} else {
// 没有父节点或父节点不存在,作为根节点
roots.push(node);
}
}
return roots;
};
// 获取部门树形结构
const fetchDepartmentTree = async () => {
try {
const response = await client.GET("/department/query-sub", {
params: {
query: {
id: undefined,
},
},
});
if (response.data) {
departmentTree.value = buildDepartmentTree(response.data);
}
return response.data || [];
} catch (error) {
console.error("获取部门树形结构失败:", error);
return [];
}
};
return {
total,
departments,
availableDepartments,
departmentTree,
total,
fetchDepartmentWith,
fetchAvailableDepartments,
fetchDepartmentTree,
};
};
}

View File

@@ -2,14 +2,22 @@ import client from "../../api/client";
import type { DepartmentUpsertModel } from "../../types/DepartmentTypes";
export const useDepartmentUpsert = () => {
const upsertDepartment = async (department: DepartmentUpsertModel) => {
await client.POST("/department", {
body: {
id: department.id,
name: department.name,
parentId: department.parentId ?? undefined,
},
});
const upsertDepartment = async (
department: DepartmentUpsertModel,
): Promise<boolean> => {
try {
const response = await client.POST("/department", {
body: {
id: department.id,
name: department.name,
parentId: department.parentId ?? undefined,
},
});
return response.response.ok;
} catch (error) {
console.error("保存部门失败:", error);
return false;
}
};
return {

View File

@@ -17,89 +17,116 @@
</template>
</TableFilterForm>
<!-- 移动端卡片布局 -->
<div class="md:hidden">
<MobileCardList :items="departments">
<template #title="{ item }">
{{ item.name }}
</template>
<template #content="{ item }">
<div>
<p class="text-xs font-medium text-gray-600">上级部门</p>
<p class="text-sm text-gray-900 mt-0.5">{{ !item.parentName ? '无' : item.parentName }}</p>
<!-- 内容布局两栏布局响应式处理 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
<!-- 左侧部门树状图仅在桌面端显示 -->
<div class="hidden lg:block lg:col-span-1">
<div class="bg-white rounded-lg shadow">
<div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">部门结构</h2>
</div>
</template>
<template #actions="{ item }">
<div class="flex gap-x-2">
<TableButton variant="primary" size="xs" isMobile @click="handleUpsertDepartmentClick(item)">
<template #icon>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"></path>
</svg>
</template>
编辑
</TableButton>
<TableButton variant="danger" size="xs" isMobile @click="handleDeleteDepartmentClick(item)">
<template #icon>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"></path>
</svg>
</template>
删除
</TableButton>
<div class="p-4">
<DepartmentTree :departmentTree="departmentTree" @add-child="handleAddChildClick"
@edit="handleEditDepartment" />
</div>
</template>
</MobileCardList>
</div>
</div>
</div>
<!-- PC端表格布局 -->
<div class="hidden md:block">
<TableFormLayout :items="departments || []" :columns="columns">
<template #parentName="{ item }">
{{ !item.parentName ? '无' : item.parentName }}
</template>
<template #name="{ item }">
{{ item.name }}
</template>
<template #actions="{ item }">
<div class="flex items-center gap-x-2">
<TableButton variant="primary" @click="handleUpsertDepartmentClick(item)">
<template #icon>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"></path>
</svg>
</template>
编辑
</TableButton>
<TableButton variant="danger" @click="handleDeleteDepartmentClick(item)">
<template #icon>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"></path>
</svg>
</template>
删除
</TableButton>
</div>
</template>
</TableFormLayout>
</div>
<!-- 右侧部门表格在移动端占满宽度 -->
<div class="col-span-1 lg:col-span-2">
<!-- 移动端卡片布局 -->
<div class="md:hidden">
<MobileCardList :items="departments">
<template #title="{ item }">
{{ item.name }}
</template>
<template #content="{ item }">
<div>
<p class="text-xs font-medium text-gray-600">上级部门</p>
<p class="text-sm text-gray-900 mt-0.5">{{ !item.parentName ? '无' : item.parentName }}</p>
</div>
</template>
<template #actions="{ item }">
<div class="flex gap-x-2">
<TableButton variant="primary" size="xs" isMobile @click="handleUpsertDepartmentClick(item)">
<template #icon>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z">
</path>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"></path>
</svg>
</template>
编辑
</TableButton>
<TableButton variant="danger" size="xs" isMobile @click="handleDeleteDepartmentClick(item)">
<template #icon>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"></path>
</svg>
</template>
删除
</TableButton>
</div>
</template>
</MobileCardList>
</div>
<TablePagination :total="total" :pageChange="handlePageChange" />
<!-- PC端表格布局 -->
<div class="hidden md:block bg-white rounded-lg shadow">
<div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">部门列表</h2>
</div>
<div class="p-4">
<TableFormLayout :items="departments || []" :columns="columns">
<template #parentName="{ item }">
{{ !item.parentName ? '无' : item.parentName }}
</template>
<template #name="{ item }">
{{ item.name }}
</template>
<template #actions="{ item }">
<div class="flex items-center gap-x-2">
<TableButton variant="primary" @click="handleUpsertDepartmentClick(item)">
<template #icon>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
<path fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"></path>
</svg>
</template>
编辑
</TableButton>
<TableButton variant="danger" @click="handleDeleteDepartmentClick(item)">
<template #icon>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"></path>
</svg>
</template>
删除
</TableButton>
</div>
</template>
</TableFormLayout>
</div>
</div>
<div class="mt-4">
<TablePagination :total="total" :pageChange="handlePageChange" />
</div>
</div>
</div>
</div>
<DepartmentDeleteModal :id="'department-delete-modal'" :closeModal="() => {
departmentDeleteModal!.hide();
}" :onSubmit="handleDeleteDepartmentSubmit" title="确定删除该部门吗" content="删除部门"></DepartmentDeleteModal>
<DepartmentFormDialog :id="'department-upsert-modal'" :onSubmit="handleUpsertDepartmentSubmit" :closeModal="() => {
availableDepartments = undefined
departmentFormDialog!.hide();
@@ -109,6 +136,7 @@
<script setup lang="ts">
import type { components } from "@/api/types/schema";
import { DepartmentTree } from "@/components/common/department";
import PlusIcon from "@/components/icons/PlusIcon.vue";
import Breadcrumbs from "@/components/layout/Breadcrumbs.vue";
import DepartmentDeleteModal from "@/components/modals/ConfirmationDialog.vue";
@@ -120,13 +148,16 @@ import type { FilterItem } from "@/components/tables/TableFilterForm.vue";
import TableFormLayout from "@/components/tables/TableFormLayout.vue";
import TablePagination from "@/components/tables/TablePagination.vue";
import useDepartmentDelete from "@/composables/department/useDepartmentDelete";
import { useDepartmentQuery } from "@/composables/department/useDepartmentQuery";
import {
type DepartmentTreeNode,
useDepartmentQuery,
} from "@/composables/department/useDepartmentQuery";
import { useDepartmentUpsert } from "@/composables/department/useDepartmentUpsert";
import { useActionExcStore } from "@/composables/store/useActionExcStore";
import useAlertStore from "@/composables/store/useAlertStore";
import type { DepartmentUpsertModel } from "@/types/DepartmentTypes";
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
import { nextTick, onMounted, reactive, ref } from "vue";
import { nextTick, onMounted, onUnmounted, reactive, ref } from "vue";
// 定义筛选配置
const filterConfig = [
@@ -160,9 +191,11 @@ const departmentDeleteModal = ref<ModalInterface>();
const {
departments,
availableDepartments,
departmentTree,
fetchDepartmentWith,
total,
fetchAvailableDepartments,
fetchDepartmentTree,
total,
} = useDepartmentQuery();
const { deleteDepartment } = useDepartmentDelete();
@@ -177,42 +210,105 @@ const columns = [
{ title: "操作", field: "actions" },
];
onMounted(async () => {
await fetchDepartmentWith({
name: filterValues.departmentName,
// 同步更新部门数据和树形结构
const refreshDepartmentData = async () => {
// 更新部门列表数据和树形结构
await Promise.all([
fetchDepartmentWith({
name: filterValues.departmentName,
}),
fetchDepartmentTree(),
]);
};
// 处理添加子部门点击
const handleAddChildClick = async (parentNode: DepartmentTreeNode) => {
// 创建默认的空部门但设置父部门ID
selectedDepartment.value = {
parentId: parentNode.id,
} as components["schemas"]["Department"];
await fetchAvailableDepartments();
await nextTick(() => {
departmentFormDialog.value?.show();
});
};
// 处理编辑部门
const handleEditDepartment = async (node: DepartmentTreeNode) => {
// 将节点转换为部门对象
const department: components["schemas"]["Department"] = {
id: node.id,
name: node.name,
parentId: node.parentId !== null ? node.parentId : undefined,
};
await handleUpsertDepartmentClick(department);
};
onMounted(async () => {
// 初始化加载部门数据和树形结构
await refreshDepartmentData();
initFlowbite();
const $upsertModalElement: HTMLElement | null = document.querySelector(
const $upsertModalElement = document.querySelector<HTMLElement>(
"#department-upsert-modal",
);
const $deleteModalElement: HTMLElement | null = document.querySelector(
const $deleteModalElement = document.querySelector<HTMLElement>(
"#department-delete-modal",
);
if ($upsertModalElement) {
departmentFormDialog.value = new Modal($upsertModalElement, {});
}
if ($deleteModalElement) {
departmentDeleteModal.value = new Modal($deleteModalElement, {});
}
actionExcStore.setCallback((result) => {
if (result) {
handleSearch();
refreshDepartmentData();
}
});
});
// 组件卸载时清理资源
onUnmounted(() => {
// 重置回调,避免内存泄漏
actionExcStore.setCallback(() => {});
// 清理模态框
departmentFormDialog.value?.hide();
departmentDeleteModal.value?.hide();
});
const handleUpsertDepartmentSubmit = async (
department: DepartmentUpsertModel,
) => {
await upsertDepartment(department);
const success = await upsertDepartment(department);
departmentFormDialog.value?.hide();
alertStore.showAlert({
content: "操作成功",
level: "success",
});
await fetchDepartmentWith({
name: filterValues.departmentName,
});
if (success) {
alertStore.showAlert({
content: department.id ? "部门更新成功" : "部门创建成功",
level: "success",
});
// 同时刷新部门列表和树形结构
await refreshDepartmentData();
// 如果是新增或修改部门,重置筛选条件
if (!department.id || selectedDepartment.value?.id !== department.id) {
filterValues.departmentName = "";
}
} else {
alertStore.showAlert({
content: "操作失败,请稍后重试",
level: "error",
});
}
// 操作完成后清空选中部门
selectedDepartment.value = undefined;
availableDepartments.value = undefined;
};
const handleUpsertDepartmentClick = async (
@@ -227,15 +323,27 @@ const handleUpsertDepartmentClick = async (
const handleDeleteDepartmentSubmit = async () => {
if (!selectedDepartment.value?.id) return;
await deleteDepartment(selectedDepartment.value.id);
const success = await deleteDepartment(selectedDepartment.value.id);
departmentDeleteModal.value?.hide();
alertStore.showAlert({
content: "删除成功",
level: "success",
});
await fetchDepartmentWith({
name: filterValues.departmentName,
});
if (success) {
alertStore.showAlert({
content: "部门删除成功",
level: "success",
});
// 删除后清空筛选条件,并刷新数据
filterValues.departmentName = "";
await refreshDepartmentData();
} else {
alertStore.showAlert({
content: "删除失败,请稍后重试",
level: "error",
});
}
// 操作完成后清空选中部门
selectedDepartment.value = undefined;
};
const handleDeleteDepartmentClick = async (