feat(dept): 部门管理新增 CUSTOM 自定义部门页签
build / build (push) Successful in 2m7s

- 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:
chenx
2026-10-22 09:36:18 +08:00
parent b8c55a0654
commit 83a73dfcc8
6 changed files with 1660 additions and 16 deletions
+17
View File
@@ -35,6 +35,23 @@ export default {
'SysGroup.setIncludeChildren': ['POST', '/api/sysgroup/SetIncludeChildren/{id}'], 'SysGroup.setIncludeChildren': ['POST', '/api/sysgroup/SetIncludeChildren/{id}'],
'SysGroup.batchAdd': ['POST', '/api/sysgroup/BatchAdd'], 'SysGroup.batchAdd': ['POST', '/api/sysgroup/BatchAdd'],
'SysGroup.sysGroupAdd': ['POST', '/api/sysgroup/Add'], '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.test': ['POST', '/api/push/Test'],
'PushSubscription.retry': ['POST', '/api/push/Retry/{id}'], 'PushSubscription.retry': ['POST', '/api/push/Retry/{id}'],
'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'], 'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'],
+541
View File
@@ -151,6 +151,56 @@ export interface SysGroupRequest {
export interface SysGroupIncludeChildrenRequest { export interface SysGroupIncludeChildrenRequest {
includeChildren: boolean; 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 { export interface PushTestRequest {
appId?: number; appId?: number;
interfacePath?: string; interfacePath?: string;
@@ -247,6 +297,10 @@ export interface SysGroupVo {
name?: string; name?: string;
type?: number; type?: number;
sort?: number; sort?: number;
/** 启停状态:0=启用 / 1=停用(存量 null 按启用处理) */
status?: number;
/** 包含子组:1=含下级部门 / 0=仅本级(仅学校节点有值) */
includeChildren?: number;
children?: SysGroupVo[]; children?: SysGroupVo[];
} }
export interface AppItem { export interface AppItem {
@@ -1783,6 +1837,493 @@ declare global {
config?: Config config?: Config
): Alova2Method<SysGroupVo[], 'SysGroup.getSchoolTree', 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: { PushSubscription: {
/** /**
* --- * ---
+199
View File
@@ -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>
+552
View File
@@ -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>
+290
View File
@@ -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>
+46 -1
View File
@@ -17,12 +17,21 @@
<Select v-if="multiSchool" v-model="currentSchoolId" size="small" class="ml-8 school-select"> <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> <Option v-for="s in schoolOptions" :key="s.id" :value="s.id">{{ s.name }}</Option>
</Select> </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> <span class="inc-label">包含子组</span>
<i class="sw-mini" :class="{ 'is-on': includeOn }"></i> <i class="sw-mini" :class="{ 'is-on': includeOn }"></i>
</span> </span>
</div> </div>
<template v-if="source === 'SYSTEM'">
<Alert show-icon type="success" class="usage-note"> <Alert show-icon type="success" class="usage-note">
<b>设置说明:</b> <b>设置说明:</b>
停用只影响「选」(新建事项 / 流程绑定时选不到),不影响「看」(已有事项展示)与「办」(在途流程照常解析); 停用只影响「选」(新建事项 / 流程绑定时选不到),不影响「看」(已有事项展示)与「办」(在途流程照常解析);
@@ -39,6 +48,9 @@
<DeptCard v-for="d in deptList" :key="d.id" :dept="d" :include-children="includeOn" <DeptCard v-for="d in deptList" :key="d.id" :dept="d" :include-children="includeOn"
@toggle="onToggle" /> @toggle="onToggle" />
</div> </div>
</template>
<CustomDeptPanel v-else :school-id="currentSchoolId" />
</div> </div>
</template> </template>
</div> </div>
@@ -51,6 +63,7 @@ import Apis from "@/api";
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue"; import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import DeptCard from "./DeptCard.vue"; import DeptCard from "./DeptCard.vue";
import CustomDeptPanel from "./CustomDeptPanel.vue";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import { UserRoleEnum } from "@/types"; import { UserRoleEnum } from "@/types";
@@ -73,6 +86,8 @@ const roleReady = computed(() => store.roleLoaded);
const refTreePanel = ref<InstanceType<typeof GroupTreeSearchPanel>>(); const refTreePanel = ref<InstanceType<typeof GroupTreeSearchPanel>>();
const selectedNode = ref<any>({}); const selectedNode = ref<any>({});
/** 部门来源页签:SYSTEM=原组织树视图(行为零改动);CUSTOM=学校自建分类/部门 */
const source = ref<"SYSTEM" | "CUSTOM">("SYSTEM");
/** 全量组树:右侧部门数据一律从这里按 id 回查真实 children */ /** 全量组树:右侧部门数据一律从这里按 id 回查真实 children */
const fullTree = ref<any[]>([]); const fullTree = ref<any[]>([]);
/** 当前学校:超管由树选中,学校管理员默认取可管理学校 */ /** 当前学校:超管由树选中,学校管理员默认取可管理学校 */
@@ -167,6 +182,8 @@ async function toggleInclude() {
school.includeChildren = next ? 1 : 0; school.includeChildren = next ? 1 : 0;
Message.success(next ? "已包含下级部门" : "仅显示一级部门"); Message.success(next ? "已包含下级部门" : "仅显示一级部门");
} catch (e: any) { } catch (e: any) {
// 全局 alova 拦截器(alova-iview 的 onBizError/onHttpError)已统一 Message.error,此处只记日志避免二次弹窗;
// 本地字段只在写成功后改,失败即自然停在原值,无需回滚
console.warn(e?.message || "设置包含子组失败"); console.warn(e?.message || "设置包含子组失败");
} }
} }
@@ -283,6 +300,34 @@ async function loadSchoolScope() {
width: 180px; 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 自身,不覆写配色) */ /* 面板内顶部说明条的间距(外观完全交给 iView Alert 自身,不覆写配色) */
.usage-note { .usage-note {