Merge branch 'dev'

This commit is contained in:
Chuck1sn
2025-06-19 15:30:04 +08:00
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.PageResponseDto;
import com.zl.mjga.dto.department.DepartmentQueryDto; import com.zl.mjga.dto.department.DepartmentQueryDto;
import com.zl.mjga.dto.department.DepartmentRespDto; import com.zl.mjga.dto.department.DepartmentRespDto;
import com.zl.mjga.dto.department.DepartmentWithParentDto;
import com.zl.mjga.repository.DepartmentRepository; import com.zl.mjga.repository.DepartmentRepository;
import com.zl.mjga.service.DepartmentService; import com.zl.mjga.service.DepartmentService;
import java.util.List; import java.util.List;
@@ -37,6 +38,12 @@ public class DepartmentController {
return departmentService.queryAvailableParentDepartmentsBy(id); 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)") @PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_DEPARTMENT_PERMISSION)")
@DeleteMapping() @DeleteMapping()
void deleteDepartment(@RequestParam Long id) { void deleteDepartment(@RequestParam Long id) {

View File

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

View File

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

View File

@@ -532,6 +532,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/department/query-available": {
parameters: { parameters: {
query?: never; query?: never;
@@ -884,6 +900,15 @@ export interface components {
total?: number; total?: number;
data?: components["schemas"]["PermissionRespDto"][]; data?: components["schemas"]["PermissionRespDto"][];
}; };
DepartmentWithParentDto: {
/** Format: int64 */
id: number;
name: string;
/** Format: int64 */
parentId: number;
parentName: string;
path: string;
};
DepartmentQueryDto: { DepartmentQueryDto: {
/** Format: int64 */ /** Format: int64 */
userId?: number; 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: { queryAvailableParentDepartmentsBy: {
parameters: { parameters: {
query?: { 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"; import client from "@/api/client";
export const useDepartmentDelete = () => { export const useDepartmentDelete = () => {
const deleteDepartment = async (departmentId: number) => { const deleteDepartment = async (departmentId: number): Promise<boolean> => {
await client.DELETE("/department", { try {
params: { const response = await client.DELETE("/department", {
query: { params: {
id: departmentId, query: {
id: departmentId,
},
}, },
}, });
}); return response.response.ok;
} catch (error) {
console.error("删除部门失败:", error);
return false;
}
}; };
return { return {
deleteDepartment, deleteDepartment,

View File

@@ -1,51 +1,151 @@
import client from "@/api/client"; import client from "@/api/client";
import type { components } from "@/api/types/schema";
import { ref } from "vue"; import { ref } from "vue";
import type { components } from "../../api/types/schema";
export const useDepartmentQuery = () => { // 定义部门树节点类型
const total = ref<number>(0); export interface DepartmentTreeNode {
const departments = ref<components["schemas"]["DepartmentRespDto"][]>(); 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 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 ( const fetchDepartmentWith = async (
param: { params: {
name?: string; name?: string;
enable?: boolean; } = {},
userId?: number;
bindState?: "ALL" | "BIND" | "UNBIND";
},
page = 1, page = 1,
size = 10, pageSize = 10,
) => { ) => {
const { data } = await client.GET("/department/page-query", { try {
params: { const response = await client.GET("/department/page-query", {
query: { params: {
pageRequestDto: { query: {
page, pageRequestDto: {
size, page,
size: pageSize,
},
departmentQueryDto: {
name: params.name || "",
},
}, },
departmentQueryDto: param,
}, },
}, });
});
total.value = !data || !data.total ? 0 : data.total; if (response.data) {
departments.value = data?.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 { return {
total,
departments, departments,
availableDepartments, availableDepartments,
departmentTree,
total,
fetchDepartmentWith, fetchDepartmentWith,
fetchAvailableDepartments, fetchAvailableDepartments,
fetchDepartmentTree,
}; };
}; }

View File

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

View File

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