- index.vue 来源切换「系统部门|自定义部门」,SYSTEM 原 DOM 原样 v-if 包装零逻辑改动,学校上下文共用 - CustomDeptPanel/CustomDeptCard:分类增删改排序(非空禁删)、部门增改移启停软删(风险二次确认) - CustomMemberModal:候选走 CandidateUsers(服务端同口径排除学生组),搜索+勾选+批量添加 - apiDefinitions/globals.d.ts 补 17 个 /api 契约(key 与后端 operationId 严格一致) - 遗留:npm run api:gen 待后端可起环境补跑校验
This commit is contained in:
@@ -35,6 +35,23 @@ export default {
|
||||
'SysGroup.setIncludeChildren': ['POST', '/api/sysgroup/SetIncludeChildren/{id}'],
|
||||
'SysGroup.batchAdd': ['POST', '/api/sysgroup/BatchAdd'],
|
||||
'SysGroup.sysGroupAdd': ['POST', '/api/sysgroup/Add'],
|
||||
'CustomDeptCategory.customDeptCategoryList': ['GET', '/api/deptcategory/List'],
|
||||
'CustomDeptCategory.customDeptCategoryAdd': ['POST', '/api/deptcategory/Add'],
|
||||
'CustomDeptCategory.customDeptCategoryEdit': ['POST', '/api/deptcategory/Edit/{id}'],
|
||||
'CustomDeptCategory.customDeptCategorySort': ['POST', '/api/deptcategory/Sort'],
|
||||
'CustomDeptCategory.customDeptCategoryDelete': ['POST', '/api/deptcategory/Delete/{id}'],
|
||||
'CustomDept.customDeptList': ['GET', '/api/customdept/List'],
|
||||
'CustomDept.customDeptAdd': ['POST', '/api/customdept/Add'],
|
||||
'CustomDept.customDeptEdit': ['POST', '/api/customdept/Edit/{id}'],
|
||||
'CustomDept.customDeptMove': ['POST', '/api/customdept/Move/{id}'],
|
||||
'CustomDept.customDeptSort': ['POST', '/api/customdept/Sort'],
|
||||
'CustomDept.customDeptDisable': ['POST', '/api/customdept/Disable/{id}'],
|
||||
'CustomDept.customDeptEnable': ['POST', '/api/customdept/Enable/{id}'],
|
||||
'CustomDept.customDeptDelete': ['POST', '/api/customdept/Delete/{id}'],
|
||||
'CustomDept.customDeptMembers': ['GET', '/api/customdept/Members/{id}'],
|
||||
'CustomDept.customDeptCandidateUsers': ['GET', '/api/customdept/CandidateUsers/{id}'],
|
||||
'CustomDept.customDeptAddMembers': ['POST', '/api/customdept/AddMembers/{id}'],
|
||||
'CustomDept.customDeptRemoveMembers': ['POST', '/api/customdept/RemoveMembers/{id}'],
|
||||
'PushSubscription.test': ['POST', '/api/push/Test'],
|
||||
'PushSubscription.retry': ['POST', '/api/push/Retry/{id}'],
|
||||
'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'],
|
||||
|
||||
Vendored
+541
@@ -151,6 +151,56 @@ export interface SysGroupRequest {
|
||||
export interface SysGroupIncludeChildrenRequest {
|
||||
includeChildren: boolean;
|
||||
}
|
||||
export interface CustomDeptCategoryRequest {
|
||||
schoolId?: number;
|
||||
name: string;
|
||||
}
|
||||
export interface CustomDeptCategorySortRequest {
|
||||
schoolId?: number;
|
||||
ids: number[];
|
||||
}
|
||||
export interface CustomDeptCategoryVo {
|
||||
id?: number;
|
||||
schoolId?: number;
|
||||
name?: string;
|
||||
sortNo?: number;
|
||||
/** 该分类下有效部门数;>0 时分类不允许删除 */
|
||||
departmentCount?: number;
|
||||
}
|
||||
export interface CustomDepartmentRequest {
|
||||
schoolId?: number;
|
||||
categoryId?: number;
|
||||
name: string;
|
||||
}
|
||||
export interface CustomDepartmentMoveRequest {
|
||||
schoolId?: number;
|
||||
categoryId: number;
|
||||
}
|
||||
export interface CustomDepartmentSortRequest {
|
||||
schoolId?: number;
|
||||
categoryId?: number;
|
||||
ids: number[];
|
||||
}
|
||||
export interface CustomDepartmentMemberRequest {
|
||||
schoolId?: number;
|
||||
userIds: number[];
|
||||
}
|
||||
export interface CustomDepartmentMemberVo {
|
||||
userId?: number;
|
||||
account?: string;
|
||||
name?: string;
|
||||
}
|
||||
export interface CustomDepartmentVo {
|
||||
id?: number;
|
||||
schoolId?: number;
|
||||
categoryId?: number;
|
||||
categoryName?: string;
|
||||
name?: string;
|
||||
sortNo?: number;
|
||||
/** true=启用 / false=停用(后端 CUSTOM 侧统一输出 boolean) */
|
||||
enabled?: boolean;
|
||||
memberCount?: number;
|
||||
}
|
||||
export interface PushTestRequest {
|
||||
appId?: number;
|
||||
interfacePath?: string;
|
||||
@@ -247,6 +297,10 @@ export interface SysGroupVo {
|
||||
name?: string;
|
||||
type?: number;
|
||||
sort?: number;
|
||||
/** 启停状态:0=启用 / 1=停用(存量 null 按启用处理) */
|
||||
status?: number;
|
||||
/** 包含子组:1=含下级部门 / 0=仅本级(仅学校节点有值) */
|
||||
includeChildren?: number;
|
||||
children?: SysGroupVo[];
|
||||
}
|
||||
export interface AppItem {
|
||||
@@ -1783,6 +1837,493 @@ declare global {
|
||||
config?: Config
|
||||
): Alova2Method<SysGroupVo[], 'SysGroup.getSchoolTree', Config>;
|
||||
};
|
||||
CustomDeptCategory: {
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [GET]
|
||||
*
|
||||
* **path:** /api/deptcategory/List
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Query Parameters**
|
||||
* ```ts
|
||||
* type QueryParameters = {
|
||||
* schoolId?: number
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = Array<CustomDeptCategoryVo>
|
||||
* ```
|
||||
*/
|
||||
customDeptCategoryList<
|
||||
Config extends Alova2MethodConfig<CustomDeptCategoryVo[]> & {
|
||||
params?: {
|
||||
schoolId?: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDeptCategoryVo[], 'CustomDeptCategory.customDeptCategoryList', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/deptcategory/Add
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDeptCategoryRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = CustomDeptCategoryVo
|
||||
* ```
|
||||
*/
|
||||
customDeptCategoryAdd<
|
||||
Config extends Alova2MethodConfig<CustomDeptCategoryVo> & {
|
||||
data: CustomDeptCategoryRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDeptCategoryVo, 'CustomDeptCategory.customDeptCategoryAdd', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/deptcategory/Edit/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDeptCategoryRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = CustomDeptCategoryVo
|
||||
* ```
|
||||
*/
|
||||
customDeptCategoryEdit<
|
||||
Config extends Alova2MethodConfig<CustomDeptCategoryVo> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: CustomDeptCategoryRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDeptCategoryVo, 'CustomDeptCategory.customDeptCategoryEdit', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/deptcategory/Sort
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDeptCategorySortRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptCategorySort<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
data: CustomDeptCategorySortRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDeptCategory.customDeptCategorySort', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/deptcategory/Delete/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptCategoryDelete<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDeptCategory.customDeptCategoryDelete', Config>;
|
||||
};
|
||||
CustomDept: {
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [GET]
|
||||
*
|
||||
* **path:** /api/customdept/List
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Query Parameters**
|
||||
* ```ts
|
||||
* type QueryParameters = {
|
||||
* categoryId: number
|
||||
* schoolId?: number
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = Array<CustomDepartmentVo>
|
||||
* ```
|
||||
*/
|
||||
customDeptList<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentVo[]> & {
|
||||
params: {
|
||||
categoryId: number;
|
||||
schoolId?: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentVo[], 'CustomDept.customDeptList', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Add
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = CustomDepartmentVo
|
||||
* ```
|
||||
*/
|
||||
customDeptAdd<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
|
||||
data: CustomDepartmentRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptAdd', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Edit/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = CustomDepartmentVo
|
||||
* ```
|
||||
*/
|
||||
customDeptEdit<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: CustomDepartmentRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptEdit', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Move/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentMoveRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = CustomDepartmentVo
|
||||
* ```
|
||||
*/
|
||||
customDeptMove<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: CustomDepartmentMoveRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptMove', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Sort
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentSortRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptSort<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
data: CustomDepartmentSortRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptSort', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Disable/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptDisable<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptDisable', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Enable/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptEnable<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptEnable', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/Delete/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptDelete<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptDelete', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [GET]
|
||||
*
|
||||
* **path:** /api/customdept/Members/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = Array<CustomDepartmentMemberVo>
|
||||
* ```
|
||||
*/
|
||||
customDeptMembers<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentMemberVo[]> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentMemberVo[], 'CustomDept.customDeptMembers', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [GET]
|
||||
*
|
||||
* **path:** /api/customdept/CandidateUsers/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Query Parameters**
|
||||
* ```ts
|
||||
* type QueryParameters = {
|
||||
* keyword?: string
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = Array<CustomDepartmentMemberVo>
|
||||
* ```
|
||||
*/
|
||||
customDeptCandidateUsers<
|
||||
Config extends Alova2MethodConfig<CustomDepartmentMemberVo[]> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
params?: {
|
||||
keyword?: string;
|
||||
};
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<CustomDepartmentMemberVo[], 'CustomDept.customDeptCandidateUsers', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/AddMembers/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentMemberRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptAddMembers<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: CustomDepartmentMemberRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptAddMembers', Config>;
|
||||
/**
|
||||
* ---
|
||||
*
|
||||
* [POST]
|
||||
*
|
||||
* **path:** /api/customdept/RemoveMembers/{id}
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Request Body**
|
||||
* ```ts
|
||||
* type RequestBody = CustomDepartmentMemberRequest
|
||||
* ```
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* **Response**
|
||||
* ```ts
|
||||
* type Response = null
|
||||
* ```
|
||||
*/
|
||||
customDeptRemoveMembers<
|
||||
Config extends Alova2MethodConfig<null> & {
|
||||
pathParams: {
|
||||
id: number;
|
||||
};
|
||||
data: CustomDepartmentMemberRequest;
|
||||
}
|
||||
>(
|
||||
config: Config
|
||||
): Alova2Method<null, 'CustomDept.customDeptRemoveMembers', Config>;
|
||||
};
|
||||
PushSubscription: {
|
||||
/**
|
||||
* ---
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<article class="cdept-card" :class="{ 'is-off': !dept.enabled }">
|
||||
<div class="row-1">
|
||||
<span class="status-dot" :class="{ 'is-off': !dept.enabled }"></span>
|
||||
<h3 class="card-name" :title="dept.name">{{ dept.name }}</h3>
|
||||
<span class="sw" :class="{ 'is-off': !dept.enabled }" :title="dept.enabled ? '点击停用' : '点击启用'"
|
||||
@click="emit('toggle', dept)"></span>
|
||||
</div>
|
||||
<div class="row-2">
|
||||
<span class="sub">{{ memberText }}</span>
|
||||
<span class="ops">
|
||||
<i class="op" :class="{ 'is-disabled': !canMoveUp }" title="上移" @click="emit('moveUp')">↑</i>
|
||||
<i class="op" :class="{ 'is-disabled': !canMoveDown }" title="下移" @click="emit('moveDown')">↓</i>
|
||||
<a class="link" @click="emit('members', dept)">成员</a>
|
||||
<a class="link" @click="emit('edit', dept)">编辑</a>
|
||||
<a class="link danger" @click="emit('remove', dept)">删除</a>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import type { CustomDepartmentVo } from "@/api/globals";
|
||||
|
||||
/**
|
||||
* CUSTOM 部门卡片:与 SYSTEM 的 DeptCard 同款外观,但字段来自 ub_custom_department。
|
||||
* 停用在管理端照常可见(灰态),软删的部门由后端列表过滤、不会走到这里。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
dept: CustomDepartmentVo;
|
||||
canMoveUp?: boolean;
|
||||
canMoveDown?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle", dept: CustomDepartmentVo): void;
|
||||
(e: "edit", dept: CustomDepartmentVo): void;
|
||||
(e: "remove", dept: CustomDepartmentVo): void;
|
||||
(e: "members", dept: CustomDepartmentVo): void;
|
||||
(e: "moveUp"): void;
|
||||
(e: "moveDown"): void;
|
||||
}>();
|
||||
|
||||
const memberText = computed(() => {
|
||||
const count = Number(props.dept?.memberCount ?? 0);
|
||||
if (!props.dept?.enabled) return count ? `已停用 · ${count} 人` : "已停用 · 无成员";
|
||||
return count ? `${count} 人` : "无成员(可先建部门再配人)";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cdept-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 13px 14px;
|
||||
background: var(--panel, #fff);
|
||||
border: 1px solid var(--line, #e9edf5);
|
||||
border-radius: var(--r, 10px);
|
||||
box-shadow: 0 1px 2px rgba(30, 38, 53, .03);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
|
||||
.cdept-card:hover {
|
||||
border-color: #dbe2fb;
|
||||
box-shadow: 0 6px 18px rgba(30, 38, 53, .07);
|
||||
}
|
||||
|
||||
.cdept-card.is-off {
|
||||
background: #fbfcfd;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.row-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
flex: 0 0 6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--green, #20a162);
|
||||
}
|
||||
|
||||
.status-dot.is-off {
|
||||
background: #cbd2df;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--text, #1e2635);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cdept-card.is-off .card-name {
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.row-2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
margin-top: auto;
|
||||
padding-top: 9px;
|
||||
border-top: 1px dashed var(--line-2, #f0f3f9);
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.row-2 .sub {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ops {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--brand, #577afb);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link.danger {
|
||||
color: #e54545;
|
||||
}
|
||||
|
||||
.op {
|
||||
font-style: normal;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #5a6478);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.op:hover {
|
||||
background: #e8ecfa;
|
||||
color: var(--brand, #577afb);
|
||||
}
|
||||
|
||||
.op.is-disabled {
|
||||
color: #d3d9e6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 启停开关(与 SYSTEM 卡片同一套视觉) */
|
||||
.sw {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: var(--brand, #577afb);
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
|
||||
.sw::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(30, 38, 53, .22);
|
||||
transition: left .15s;
|
||||
}
|
||||
|
||||
.sw.is-off {
|
||||
background: #d3d9e6;
|
||||
}
|
||||
|
||||
.sw.is-off::after {
|
||||
left: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,552 @@
|
||||
<template>
|
||||
<div class="custom-panel">
|
||||
<Alert v-if="!schoolId" show-icon type="info" class="state-tip">
|
||||
请在左侧选择学校节点(或切换顶部学校)后再管理自定义部门。
|
||||
</Alert>
|
||||
|
||||
<template v-else>
|
||||
<div class="custom-body">
|
||||
<!-- 左侧:分类是平铺列表,不是树(空 children 也不能挂成树,避免 ± 展开) -->
|
||||
<div class="cat-col">
|
||||
<div class="col-head">
|
||||
<span class="col-title">自定义分类</span>
|
||||
<Button size="small" type="primary" @click="openCategoryCreate">新增分类</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 }" @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>
|
||||
|
||||
<!-- 右侧:当前分类下的部门卡片 -->
|
||||
<div class="dept-col">
|
||||
<div class="col-head">
|
||||
<span class="col-title">{{ activeCategoryName || '部门' }}</span>
|
||||
<Button size="small" type="primary" :disabled="!activeCategoryId" @click="openDeptCreate">
|
||||
新增部门
|
||||
</Button>
|
||||
</div>
|
||||
<Alert v-if="!activeCategoryId" show-icon type="warning" class="state-tip">
|
||||
请先在左侧选择一个分类,或新建一个分类。
|
||||
</Alert>
|
||||
<div v-else-if="loadingDepartments" class="col-tip">加载中…</div>
|
||||
<div v-else-if="!departments.length" class="col-tip">
|
||||
该分类下暂无部门。部门可先新建再配成员,成员为空时不影响保存。
|
||||
</div>
|
||||
<div v-else class="dept-grid">
|
||||
<CustomDeptCard v-for="(d, idx) in departments" :key="d.id" :dept="d"
|
||||
:can-move-up="idx > 0" :can-move-down="idx < departments.length - 1"
|
||||
@toggle="onToggleDept" @edit="openDeptEdit" @remove="removeDept"
|
||||
@members="openMembers" @move-up="moveDept(idx, -1)" @move-down="moveDept(idx, 1)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 名称录入(分类新增/改名、部门新增/改名共用) -->
|
||||
<Modal v-model="nameModal.visible" :title="nameModal.title" :mask-closable="false" width="420">
|
||||
<Form :label-width="72">
|
||||
<FormItem :label="nameModal.kind === 'dept' ? '部门名称' : '分类名称'">
|
||||
<Input v-model="nameModal.name" :placeholder="nameModal.placeholder"
|
||||
maxlength="64" show-word-limit />
|
||||
</FormItem>
|
||||
<FormItem v-if="nameModal.kind === 'dept' && 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>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="nameModal.visible = false">取消</Button>
|
||||
<Button type="primary" :loading="nameModal.saving" @click="submitNameModal">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<CustomMemberModal v-model:visible="memberModal.visible" :dept="memberModal.dept"
|
||||
@success="loadDepartments" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import type { CustomDeptCategoryVo, CustomDepartmentVo } from "@/api/globals";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import CustomDeptCard from "./CustomDeptCard.vue";
|
||||
import CustomMemberModal from "./CustomMemberModal.vue";
|
||||
|
||||
/**
|
||||
* CUSTOM 自定义部门管理面板(部门管理页的第二个来源页签)。
|
||||
* 口径:分类(平铺列表,容器)→ 部门(可启停 / 可配成员 / 软删)→ 成员(本校教职工,服务端排除学生组)。
|
||||
* 学校上下文由父级传入,和 SYSTEM 页签共用,切换来源不改学校。
|
||||
*/
|
||||
const props = defineProps<{ schoolId?: number | null }>();
|
||||
|
||||
const categories = ref<CustomDeptCategoryVo[]>([]);
|
||||
const departments = ref<CustomDepartmentVo[]>([]);
|
||||
const activeCategoryId = ref<number | null>(null);
|
||||
const loadingCategories = ref(false);
|
||||
const loadingDepartments = ref(false);
|
||||
|
||||
const activeCategory = computed(() => categories.value.find((c) => c.id === activeCategoryId.value) || null);
|
||||
const activeCategoryName = computed(() => activeCategory.value?.name || "");
|
||||
|
||||
const nameModal = reactive({
|
||||
visible: false,
|
||||
saving: false,
|
||||
kind: "category" as "category" | "dept",
|
||||
isEdit: false,
|
||||
id: null as number | null,
|
||||
name: "",
|
||||
categoryId: null as number | null,
|
||||
title: "",
|
||||
placeholder: "",
|
||||
});
|
||||
|
||||
const memberModal = reactive({ visible: false, dept: null as CustomDepartmentVo | null });
|
||||
|
||||
/* ================= 数据加载 ================= */
|
||||
|
||||
async function loadCategories(keepActive = true) {
|
||||
if (!props.schoolId) {
|
||||
categories.value = [];
|
||||
activeCategoryId.value = null;
|
||||
return;
|
||||
}
|
||||
loadingCategories.value = true;
|
||||
try {
|
||||
const rows = (await Apis.CustomDeptCategory.customDeptCategoryList({
|
||||
params: { schoolId: props.schoolId },
|
||||
cacheFor: null,
|
||||
})) as CustomDeptCategoryVo[];
|
||||
categories.value = rows || [];
|
||||
const stillThere = categories.value.some((c) => c.id === activeCategoryId.value);
|
||||
if (!keepActive || !stillThere) {
|
||||
activeCategoryId.value = categories.value[0]?.id ?? null;
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 失败提示已由全局 alova 拦截器统一弹出,此处只记日志避免二次弹窗
|
||||
console.warn(e?.message || "加载自定义分类失败");
|
||||
categories.value = [];
|
||||
} finally {
|
||||
loadingCategories.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
if (!props.schoolId || !activeCategoryId.value) {
|
||||
departments.value = [];
|
||||
return;
|
||||
}
|
||||
loadingDepartments.value = true;
|
||||
try {
|
||||
const rows = (await Apis.CustomDept.customDeptList({
|
||||
params: { schoolId: props.schoolId, categoryId: activeCategoryId.value },
|
||||
cacheFor: null,
|
||||
})) as CustomDepartmentVo[];
|
||||
departments.value = rows || [];
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "加载自定义部门失败");
|
||||
departments.value = [];
|
||||
} finally {
|
||||
loadingDepartments.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectCategory(id: number | null) {
|
||||
activeCategoryId.value = id;
|
||||
loadDepartments();
|
||||
}
|
||||
|
||||
watch(() => props.schoolId, () => {
|
||||
activeCategoryId.value = null;
|
||||
departments.value = [];
|
||||
loadCategories(false);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(activeCategoryId, () => loadDepartments());
|
||||
|
||||
/* ================= 分类操作 ================= */
|
||||
|
||||
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;
|
||||
try {
|
||||
await Apis.CustomDeptCategory.customDeptCategorySort({
|
||||
data: { schoolId: props.schoolId as number, ids: ordered.map((c) => c.id as number) },
|
||||
cacheFor: null,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "分类排序保存失败");
|
||||
await loadCategories();
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 部门操作 ================= */
|
||||
|
||||
function openDeptCreate() {
|
||||
Object.assign(nameModal, {
|
||||
kind: "dept", 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,
|
||||
title: `编辑部门「${d.name}」`, placeholder: "部门名称", visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function submitNameModal() {
|
||||
const name = nameModal.name.trim();
|
||||
if (!name) {
|
||||
Message.warning("名称不能为空");
|
||||
return;
|
||||
}
|
||||
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 },
|
||||
data: { schoolId: props.schoolId as number, name },
|
||||
cacheFor: null,
|
||||
});
|
||||
// 改名与移动分开两个写口:归属分类变了就再走一次 Move
|
||||
const current = departments.value.find((d) => d.id === nameModal.id);
|
||||
if (current && nameModal.categoryId && nameModal.categoryId !== current.categoryId) {
|
||||
await Apis.CustomDept.customDeptMove({
|
||||
pathParams: { id: nameModal.id as number },
|
||||
data: { schoolId: props.schoolId as number, categoryId: nameModal.categoryId },
|
||||
cacheFor: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await Apis.CustomDept.customDeptAdd({
|
||||
data: { schoolId: props.schoolId as number, categoryId: activeCategoryId.value as number, name },
|
||||
cacheFor: null,
|
||||
});
|
||||
}
|
||||
nameModal.visible = false;
|
||||
Message.success("已保存");
|
||||
await Promise.all([loadCategories(), loadDepartments()]);
|
||||
} catch (e: any) {
|
||||
// 同名校验等后端拒绝由全局拦截器弹出,弹窗保持打开让用户改
|
||||
console.warn(e?.message || "保存失败");
|
||||
} finally {
|
||||
nameModal.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onToggleDept(d: CustomDepartmentVo) {
|
||||
if (d.enabled) {
|
||||
MyConfirm({
|
||||
title: "停用部门",
|
||||
msg: `确定停用自定义部门【${d.name}】?`,
|
||||
info: "停用只影响「选」(新建事项 / 流程绑定时选不到),不影响「看」和「办」(已有事项展示、在途流程照常解析)。",
|
||||
async onOk() {
|
||||
await disableDept(d);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
enableDept(d);
|
||||
}
|
||||
|
||||
async function disableDept(d: CustomDepartmentVo) {
|
||||
try {
|
||||
await Apis.CustomDept.customDeptDisable({ pathParams: { id: d.id as number } });
|
||||
Message.success("已停用");
|
||||
await loadDepartments();
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "停用失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDept(d: CustomDepartmentVo) {
|
||||
try {
|
||||
await Apis.CustomDept.customDeptEnable({ pathParams: { id: d.id as number } });
|
||||
Message.success("已启用");
|
||||
await loadDepartments();
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "启用失败");
|
||||
}
|
||||
}
|
||||
|
||||
function removeDept(d: CustomDepartmentVo) {
|
||||
MyConfirm({
|
||||
title: "删除部门",
|
||||
msg: `确定删除自定义部门【${d.name}】?`,
|
||||
info: "删除是软删除:原 ID 不再复用,已有事项 / 流程的引用仍可解析出名称,但新配置里不再可选。请确认没有业务正在用它。",
|
||||
async onOk() {
|
||||
await doRemoveDept(d);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function doRemoveDept(d: CustomDepartmentVo) {
|
||||
try {
|
||||
await Apis.CustomDept.customDeptDelete({ pathParams: { id: d.id as number } });
|
||||
Message.success("已删除");
|
||||
await Promise.all([loadCategories(), loadDepartments()]);
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 部门上移/下移:同分类内整表重排提交 */
|
||||
async function moveDept(idx: number, delta: number) {
|
||||
const target = idx + delta;
|
||||
if (target < 0 || target >= departments.value.length) return;
|
||||
const ordered = [...departments.value];
|
||||
const [row] = ordered.splice(idx, 1);
|
||||
ordered.splice(target, 0, row);
|
||||
departments.value = ordered;
|
||||
try {
|
||||
await Apis.CustomDept.customDeptSort({
|
||||
data: {
|
||||
schoolId: props.schoolId as number,
|
||||
categoryId: activeCategoryId.value as number,
|
||||
ids: ordered.map((d) => d.id as number),
|
||||
},
|
||||
cacheFor: null,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "部门排序保存失败");
|
||||
await loadDepartments();
|
||||
}
|
||||
}
|
||||
|
||||
function openMembers(d: CustomDepartmentVo) {
|
||||
memberModal.dept = d;
|
||||
memberModal.visible = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.custom-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-body {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #1e2635);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-tip {
|
||||
padding: 26px 12px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
border: 1px dashed var(--line, #e9edf5);
|
||||
border-radius: var(--r, 10px);
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.state-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 左列:分类平铺列表 */
|
||||
.cat-col {
|
||||
flex: 0 0 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cat-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cat-item {
|
||||
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;
|
||||
font-size: 13px;
|
||||
color: var(--text, #1e2635);
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
|
||||
.cat-item:hover {
|
||||
border-color: #dbe2fb;
|
||||
background: #f7f9ff;
|
||||
}
|
||||
|
||||
.cat-item.is-active {
|
||||
border-color: var(--brand, #577afb);
|
||||
background: #f2f5ff;
|
||||
}
|
||||
|
||||
.cat-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cat-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.cat-ops {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--brand, #577afb);
|
||||
}
|
||||
|
||||
.op.is-disabled {
|
||||
color: #d3d9e6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.op.is-disabled:hover {
|
||||
background: transparent;
|
||||
color: #d3d9e6;
|
||||
}
|
||||
|
||||
/* 右列:部门卡片 */
|
||||
.dept-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dept-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(258px, 1fr));
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<Modal v-model="innerVisible" :title="title" width="720" :mask-closable="false"
|
||||
@on-visible-change="onVisibleChange">
|
||||
<div class="member-body">
|
||||
<!-- 已有成员 -->
|
||||
<div class="pane">
|
||||
<div class="pane-head">
|
||||
<span class="pane-title">当前成员</span>
|
||||
<span class="pane-sub">{{ members.length }} 人</span>
|
||||
</div>
|
||||
<div v-if="loadingMembers" class="pane-tip">加载中…</div>
|
||||
<div v-else-if="!members.length" class="pane-tip">
|
||||
该部门还没有成员。成员只影响「派人」,不影响部门本身的启停与展示。
|
||||
</div>
|
||||
<ul v-else class="pane-list">
|
||||
<li v-for="u in members" :key="u.userId" class="row">
|
||||
<span class="row-name">{{ u.name || u.account }}</span>
|
||||
<span class="row-sub">{{ u.account }}</span>
|
||||
<a class="row-op danger" @click="remove(u)">移除</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 候选教职工 -->
|
||||
<div class="pane">
|
||||
<div class="pane-head">
|
||||
<Input v-model="kw" placeholder="按账号/姓名搜索" clearable search enter-button
|
||||
@on-search="loadCandidates" @on-change="onKwChange" class="kw" />
|
||||
<span class="pane-sub">已选 {{ selectedIds.size }} 人</span>
|
||||
</div>
|
||||
<div v-if="loadingCandidates" class="pane-tip">加载中…</div>
|
||||
<div v-else-if="!candidates.length" class="pane-tip">
|
||||
没有可添加的教职工。候选=本校教职工(系统会自动排除学生组下的人员),且尚未在该部门。
|
||||
</div>
|
||||
<ul v-else class="pane-list">
|
||||
<li v-for="u in candidates" :key="u.userId" class="row">
|
||||
<Checkbox :model-value="selectedIds.has(u.userId as number)"
|
||||
@on-change="(v: any) => toggle(u.userId as number, !!v)">
|
||||
<span class="row-name">{{ u.name || u.account }}</span>
|
||||
<span class="row-sub">{{ u.account }}</span>
|
||||
</Checkbox>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="candidates.length >= CANDIDATE_PAGE_HINT" class="pane-foot">
|
||||
仅显示前 {{ CANDIDATE_PAGE_HINT }} 条,可输入关键字缩小范围。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="innerVisible = false">关闭</Button>
|
||||
<Button type="primary" :loading="saving" :disabled="!selectedIds.size" @click="addSelected">
|
||||
添加所选
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import Apis from "@/api";
|
||||
import type { CustomDepartmentMemberVo, CustomDepartmentVo } from "@/api/globals";
|
||||
|
||||
/**
|
||||
* 自定义部门成员管理弹窗。
|
||||
* 候选走 {@code /api/customdept/CandidateUsers/{id}}:服务端按「本校教职工 + 排除学生组 + 未在本部门」给列表,
|
||||
* 所以这里不需要(也不能)用组维度的 GroupUsers 当候选源——那会把学生带进来(任务书 D4)。
|
||||
*/
|
||||
const props = defineProps<{ visible: boolean; dept?: CustomDepartmentVo | null }>();
|
||||
const emit = defineEmits<{ "update:visible": [boolean]; success: [] }>();
|
||||
|
||||
/** 后端候选上限 200,达到就提示用关键字筛选 */
|
||||
const CANDIDATE_PAGE_HINT = 200;
|
||||
|
||||
const innerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (v) => emit("update:visible", v),
|
||||
});
|
||||
|
||||
const title = computed(() => `部门成员 · ${props.dept?.name || ""}`);
|
||||
|
||||
const members = ref<CustomDepartmentMemberVo[]>([]);
|
||||
const candidates = ref<CustomDepartmentMemberVo[]>([]);
|
||||
const selectedIds = ref<Set<number>>(new Set());
|
||||
const kw = ref("");
|
||||
const loadingMembers = ref(false);
|
||||
const loadingCandidates = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const deptId = computed(() => props.dept?.id ?? null);
|
||||
|
||||
async function loadMembers() {
|
||||
if (!deptId.value) return;
|
||||
loadingMembers.value = true;
|
||||
try {
|
||||
members.value = (await Apis.CustomDept.customDeptMembers({
|
||||
pathParams: { id: deptId.value },
|
||||
cacheFor: null,
|
||||
})) as CustomDepartmentMemberVo[] || [];
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "加载部门成员失败");
|
||||
members.value = [];
|
||||
} finally {
|
||||
loadingMembers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
if (!deptId.value) return;
|
||||
loadingCandidates.value = true;
|
||||
try {
|
||||
candidates.value = (await Apis.CustomDept.customDeptCandidateUsers({
|
||||
pathParams: { id: deptId.value },
|
||||
params: { keyword: kw.value.trim() || undefined },
|
||||
cacheFor: null,
|
||||
})) as CustomDepartmentMemberVo[] || [];
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "加载候选教职工失败");
|
||||
candidates.value = [];
|
||||
} finally {
|
||||
loadingCandidates.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKwChange() {
|
||||
// clearable 清空时也当一次搜索
|
||||
if (!kw.value) loadCandidates();
|
||||
}
|
||||
|
||||
function toggle(userId: number, checked: boolean) {
|
||||
if (checked) selectedIds.value.add(userId);
|
||||
else selectedIds.value.delete(userId);
|
||||
selectedIds.value = new Set(selectedIds.value);
|
||||
}
|
||||
|
||||
async function addSelected() {
|
||||
if (!deptId.value || !selectedIds.value.size) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await Apis.CustomDept.customDeptAddMembers({
|
||||
pathParams: { id: deptId.value },
|
||||
data: { schoolId: props.dept?.schoolId as number, userIds: [...selectedIds.value] },
|
||||
cacheFor: null,
|
||||
});
|
||||
Message.success("已添加");
|
||||
selectedIds.value = new Set();
|
||||
await Promise.all([loadMembers(), loadCandidates()]);
|
||||
emit("success");
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "添加成员失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(u: CustomDepartmentMemberVo) {
|
||||
if (!deptId.value || !u.userId) return;
|
||||
try {
|
||||
await Apis.CustomDept.customDeptRemoveMembers({
|
||||
pathParams: { id: deptId.value },
|
||||
data: { schoolId: props.dept?.schoolId as number, userIds: [u.userId] },
|
||||
cacheFor: null,
|
||||
});
|
||||
Message.success("已移除");
|
||||
await Promise.all([loadMembers(), loadCandidates()]);
|
||||
emit("success");
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || "移除成员失败");
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
members.value = [];
|
||||
candidates.value = [];
|
||||
selectedIds.value = new Set();
|
||||
kw.value = "";
|
||||
}
|
||||
|
||||
function onVisibleChange(v: boolean) {
|
||||
if (!v) return;
|
||||
reset();
|
||||
loadMembers();
|
||||
loadCandidates();
|
||||
}
|
||||
|
||||
// 外部直接改 visible 时同步一次
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v) onVisibleChange(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.member-body {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 440px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.pane-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #1e2635);
|
||||
}
|
||||
|
||||
.pane-sub {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kw {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pane-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line, #e9edf5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pane-tip {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
border: 1px dashed var(--line, #e9edf5);
|
||||
border-radius: 4px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.pane-foot {
|
||||
padding-top: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3, #98a1b2);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 4px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row-name {
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
}
|
||||
|
||||
.row-sub {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.row-op {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row-op.danger {
|
||||
color: #e54545;
|
||||
}
|
||||
</style>
|
||||
@@ -17,12 +17,21 @@
|
||||
<Select v-if="multiSchool" v-model="currentSchoolId" size="small" class="ml-8 school-select">
|
||||
<Option v-for="s in schoolOptions" :key="s.id" :value="s.id">{{ s.name }}</Option>
|
||||
</Select>
|
||||
<span class="inc-ctrl ml-auto" title="打开后列出该校下所有层级的部门" @click="toggleInclude">
|
||||
<!-- 来源切换:只切右侧管理视图,学校上下文共用,不改底层组织树 -->
|
||||
<div class="source-tabs ml-auto">
|
||||
<span class="source-tab" :class="{ 'is-active': source === 'SYSTEM' }"
|
||||
@click="source = 'SYSTEM'">系统部门 SYSTEM</span>
|
||||
<span class="source-tab" :class="{ 'is-active': source === 'CUSTOM' }"
|
||||
@click="source = 'CUSTOM'">自定义部门 CUSTOM</span>
|
||||
</div>
|
||||
<span v-if="source === 'SYSTEM'" class="inc-ctrl" title="打开后列出该校下所有层级的部门"
|
||||
@click="toggleInclude">
|
||||
<span class="inc-label">包含子组</span>
|
||||
<i class="sw-mini" :class="{ 'is-on': includeOn }"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="source === 'SYSTEM'">
|
||||
<Alert show-icon type="success" class="usage-note">
|
||||
<b>设置说明:</b>
|
||||
停用只影响「选」(新建事项 / 流程绑定时选不到),不影响「看」(已有事项展示)与「办」(在途流程照常解析);
|
||||
@@ -39,6 +48,9 @@
|
||||
<DeptCard v-for="d in deptList" :key="d.id" :dept="d" :include-children="includeOn"
|
||||
@toggle="onToggle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<CustomDeptPanel v-else :school-id="currentSchoolId" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -51,6 +63,7 @@ import Apis from "@/api";
|
||||
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import DeptCard from "./DeptCard.vue";
|
||||
import CustomDeptPanel from "./CustomDeptPanel.vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { UserRoleEnum } from "@/types";
|
||||
@@ -73,6 +86,8 @@ const roleReady = computed(() => store.roleLoaded);
|
||||
|
||||
const refTreePanel = ref<InstanceType<typeof GroupTreeSearchPanel>>();
|
||||
const selectedNode = ref<any>({});
|
||||
/** 部门来源页签:SYSTEM=原组织树视图(行为零改动);CUSTOM=学校自建分类/部门 */
|
||||
const source = ref<"SYSTEM" | "CUSTOM">("SYSTEM");
|
||||
/** 全量组树:右侧部门数据一律从这里按 id 回查真实 children */
|
||||
const fullTree = ref<any[]>([]);
|
||||
/** 当前学校:超管由树选中,学校管理员默认取可管理学校 */
|
||||
@@ -167,6 +182,8 @@ async function toggleInclude() {
|
||||
school.includeChildren = next ? 1 : 0;
|
||||
Message.success(next ? "已包含下级部门" : "仅显示一级部门");
|
||||
} catch (e: any) {
|
||||
// 全局 alova 拦截器(alova-iview 的 onBizError/onHttpError)已统一 Message.error,此处只记日志避免二次弹窗;
|
||||
// 本地字段只在写成功后改,失败即自然停在原值,无需回滚
|
||||
console.warn(e?.message || "设置包含子组失败");
|
||||
}
|
||||
}
|
||||
@@ -283,6 +300,34 @@ async function loadSchoolScope() {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
/* 来源切换:同页两个视图,不新增菜单项 */
|
||||
.source-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: #f2f4f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
padding: 3px 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 18px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-2, #5a6478);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-tab.is-active {
|
||||
background: var(--panel, #fff);
|
||||
color: var(--brand, #577afb);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(30, 38, 53, .08);
|
||||
}
|
||||
|
||||
/* 右侧卡片面板撑满剩余高度(与其他页面一致),内容超出时在面板内滚动 */
|
||||
/* 面板内顶部说明条的间距(外观完全交给 iView Alert 自身,不覆写配色) */
|
||||
.usage-note {
|
||||
|
||||
Reference in New Issue
Block a user