feat(@vben/web-antd): 新增区域安保配置模块
- 新增区域安保 API 接口定义 - 新增区域安保配置页面,支持区域视图和人员视图 - 包含人员绑定弹窗和人员卡片组件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
60
apps/web-antd/src/api/ops/area-security/index.ts
Normal file
60
apps/web-antd/src/api/ops/area-security/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace AreaSecurityApi {
|
||||
/** 区域-安保人员绑定记录(对应后端 OpsAreaSecurityUserRespVO) */
|
||||
export interface AreaSecurityUser {
|
||||
id: number;
|
||||
areaId: number;
|
||||
userId: number;
|
||||
userName: string;
|
||||
teamId?: number;
|
||||
enabled: boolean;
|
||||
sort: number;
|
||||
createTime?: string;
|
||||
}
|
||||
|
||||
/** 绑定安保人员请求(对应后端 OpsAreaSecurityUserBindReqVO) */
|
||||
export interface BindReq {
|
||||
areaId: number;
|
||||
userId: number;
|
||||
userName?: string;
|
||||
teamId?: number;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
/** 更新绑定请求(对应后端 OpsAreaSecurityUserUpdateReqVO) */
|
||||
export interface UpdateReq {
|
||||
id: number;
|
||||
enabled?: boolean;
|
||||
sort?: number;
|
||||
teamId?: number;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 区域安保人员绑定 API ==========
|
||||
// 后端路径前缀: /ops/security/area-user
|
||||
|
||||
/** 获取某区域已绑定的安保人员列表 */
|
||||
export function getAreaSecurityUserList(areaId: number) {
|
||||
return requestClient.get<AreaSecurityApi.AreaSecurityUser[]>(
|
||||
'/ops/security/area-user/list',
|
||||
{ params: { areaId } },
|
||||
);
|
||||
}
|
||||
|
||||
/** 绑定安保人员到区域 */
|
||||
export function bindAreaSecurityUser(data: AreaSecurityApi.BindReq) {
|
||||
return requestClient.post<number>('/ops/security/area-user/bind', data);
|
||||
}
|
||||
|
||||
/** 更新绑定信息(启用/停用、排序、团队) */
|
||||
export function updateAreaSecurityUser(data: AreaSecurityApi.UpdateReq) {
|
||||
return requestClient.put<boolean>('/ops/security/area-user/update', data);
|
||||
}
|
||||
|
||||
/** 解绑安保人员(按绑定记录 ID) */
|
||||
export function unbindAreaSecurityUser(id: number) {
|
||||
return requestClient.delete<boolean>('/ops/security/area-user/unbind', {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
79
apps/web-antd/src/views/ops/area-security/data.ts
Normal file
79
apps/web-antd/src/views/ops/area-security/data.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { OpsAreaApi } from '#/api/ops/area';
|
||||
import type { AreaSecurityApi } from '#/api/ops/area-security';
|
||||
|
||||
/**
|
||||
* 前端扩展类型:在后端返回的直接绑定基础上,增加"继承"标识。
|
||||
* 后端只存直接绑定;继承关系由前端根据区域树向上查找计算。
|
||||
*/
|
||||
export type BindType = 'DIRECT' | 'INHERITED';
|
||||
|
||||
export interface AreaSecurityUserDisplay
|
||||
extends AreaSecurityApi.AreaSecurityUser {
|
||||
/** 绑定类型 —— 直接绑定 or 从父区域继承 */
|
||||
bindType: BindType;
|
||||
/** 继承来源区域 ID(仅 INHERITED) */
|
||||
sourceAreaId?: number;
|
||||
/** 继承来源区域名称(仅 INHERITED) */
|
||||
sourceAreaName?: string;
|
||||
}
|
||||
|
||||
/** 绑定类型标签配置 */
|
||||
export const BIND_TYPE_MAP: Record<BindType, { color: string; label: string }> =
|
||||
{
|
||||
DIRECT: { label: '直接绑定', color: 'blue' },
|
||||
INHERITED: { label: '继承', color: 'default' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算某区域的完整安保人员列表(直接 + 继承)。
|
||||
*
|
||||
* 逻辑:从当前区域向上遍历所有祖先区域,
|
||||
* 每个祖先区域的绑定人员标记为 INHERITED,当前区域的标记为 DIRECT。
|
||||
* 同一个 userId 出现多次时,DIRECT 优先(不重复展示)。
|
||||
*
|
||||
* @param currentAreaId 当前选中区域 ID
|
||||
* @param areaFlatList 所有区域平铺列表
|
||||
* @param fetchBindings 获取某区域绑定列表的函数
|
||||
*/
|
||||
export async function computeFullSecurityList(
|
||||
currentAreaId: number,
|
||||
areaFlatList: OpsAreaApi.BusArea[],
|
||||
fetchBindings: (
|
||||
areaId: number,
|
||||
) => Promise<AreaSecurityApi.AreaSecurityUser[]>,
|
||||
): Promise<AreaSecurityUserDisplay[]> {
|
||||
const areaMap = new Map(areaFlatList.map((a) => [a.id, a]));
|
||||
|
||||
// 收集祖先链(从当前区域向上)
|
||||
const ancestorChain: OpsAreaApi.BusArea[] = [];
|
||||
let current = areaMap.get(currentAreaId);
|
||||
while (current) {
|
||||
ancestorChain.push(current);
|
||||
current = current.parentId ? areaMap.get(current.parentId) : undefined;
|
||||
}
|
||||
|
||||
// 逐层获取绑定并标记类型
|
||||
const seen = new Set<number>(); // userId 去重
|
||||
const result: AreaSecurityUserDisplay[] = [];
|
||||
|
||||
for (const area of ancestorChain) {
|
||||
if (!area.id) continue;
|
||||
const bindings = await fetchBindings(area.id);
|
||||
const isDirect = area.id === currentAreaId;
|
||||
|
||||
for (const binding of bindings) {
|
||||
if (!binding.enabled) continue;
|
||||
if (seen.has(binding.userId)) continue;
|
||||
seen.add(binding.userId);
|
||||
|
||||
result.push({
|
||||
...binding,
|
||||
bindType: isDirect ? 'DIRECT' : 'INHERITED',
|
||||
sourceAreaId: isDirect ? undefined : area.id,
|
||||
sourceAreaName: isDirect ? undefined : area.areaName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
43
apps/web-antd/src/views/ops/area-security/index.vue
Normal file
43
apps/web-antd/src/views/ops/area-security/index.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { TabPane, Tabs } from 'ant-design-vue';
|
||||
|
||||
import AreaView from './modules/area-view.vue';
|
||||
import StaffView from './modules/staff-view.vue';
|
||||
|
||||
defineOptions({ name: 'OpsAreaSecurity' });
|
||||
|
||||
const activeTab = ref('area');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="area-security-page">
|
||||
<Tabs v-model:active-key="activeTab" class="area-security-tabs">
|
||||
<TabPane key="area" tab="区域视角">
|
||||
<AreaView />
|
||||
</TabPane>
|
||||
<TabPane key="staff" tab="人员视角">
|
||||
<StaffView />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.area-security-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.area-security-tabs {
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.area-security-page {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
356
apps/web-antd/src/views/ops/area-security/modules/area-view.vue
Normal file
356
apps/web-antd/src/views/ops/area-security/modules/area-view.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import type { AreaSecurityUserDisplay } from '../data';
|
||||
|
||||
import type { OpsAreaApi } from '#/api/ops/area';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
message,
|
||||
Modal,
|
||||
Row,
|
||||
Spin,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getAreaTree } from '#/api/ops/area';
|
||||
import {
|
||||
getAreaSecurityUserList,
|
||||
unbindAreaSecurityUser,
|
||||
} from '#/api/ops/area-security';
|
||||
|
||||
import AreaTree from '../../components/AreaTree.vue';
|
||||
import { computeFullSecurityList } from '../data';
|
||||
import BindStaffModal from './bind-staff-modal.vue';
|
||||
import StaffCard from './staff-card.vue';
|
||||
|
||||
// ========== Area tree ==========
|
||||
const areaTreeRef = ref<InstanceType<typeof AreaTree>>();
|
||||
const selectedArea = ref<null | OpsAreaApi.BusArea>(null);
|
||||
const selectedAreaPath = ref('');
|
||||
|
||||
/** 区域平铺列表(用于继承计算) */
|
||||
const areaFlatList = ref<OpsAreaApi.BusArea[]>([]);
|
||||
|
||||
async function loadAreaFlatList() {
|
||||
try {
|
||||
areaFlatList.value = await getAreaTree({ isActive: true });
|
||||
} catch {
|
||||
areaFlatList.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
loadAreaFlatList();
|
||||
|
||||
function handleAreaSelect(area: null | OpsAreaApi.BusArea) {
|
||||
if (area && area.id !== null && area.id !== undefined) {
|
||||
selectedArea.value = area;
|
||||
selectedAreaPath.value =
|
||||
areaTreeRef.value?.getAreaPath(area.id) || area.areaName;
|
||||
fetchStaffList(area.id);
|
||||
} else {
|
||||
selectedArea.value = null;
|
||||
selectedAreaPath.value = '';
|
||||
displayList.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Staff list (direct + inherited) ==========
|
||||
const displayList = ref<AreaSecurityUserDisplay[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchStaffList(areaId: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
displayList.value = await computeFullSecurityList(
|
||||
areaId,
|
||||
areaFlatList.value,
|
||||
getAreaSecurityUserList,
|
||||
);
|
||||
} catch {
|
||||
displayList.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const directList = computed(() =>
|
||||
displayList.value.filter((s) => s.bindType === 'DIRECT'),
|
||||
);
|
||||
|
||||
const inheritedList = computed(() =>
|
||||
displayList.value.filter((s) => s.bindType === 'INHERITED'),
|
||||
);
|
||||
|
||||
function refreshList() {
|
||||
if (selectedArea.value?.id) {
|
||||
fetchStaffList(selectedArea.value.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Bind modal ==========
|
||||
const [BindModal, bindModalApi] = useVbenModal({
|
||||
connectedComponent: BindStaffModal,
|
||||
});
|
||||
|
||||
function handleAddStaff() {
|
||||
if (!selectedArea.value?.id) {
|
||||
message.warning('请先选择一个区域');
|
||||
return;
|
||||
}
|
||||
bindModalApi.setData({
|
||||
areaId: selectedArea.value.id,
|
||||
areaPath: selectedAreaPath.value,
|
||||
boundUserIds: displayList.value.map((s) => s.userId),
|
||||
});
|
||||
bindModalApi.open();
|
||||
}
|
||||
|
||||
// ========== Unbind ==========
|
||||
function handleUnbind(item: AreaSecurityUserDisplay) {
|
||||
Modal.confirm({
|
||||
title: '确认解除绑定',
|
||||
content: `确定要解除安保人员「${item.userName}」在该区域的绑定吗?`,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
await unbindAreaSecurityUser(item.id);
|
||||
message.success('已解除绑定');
|
||||
refreshList();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Go to source area ==========
|
||||
function handleGoSource(item: AreaSecurityUserDisplay) {
|
||||
if (!item.sourceAreaId) return;
|
||||
const sourceArea: OpsAreaApi.BusArea = {
|
||||
id: item.sourceAreaId,
|
||||
areaName: item.sourceAreaName || '',
|
||||
};
|
||||
handleAreaSelect(sourceArea);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Row :gutter="12" class="layout-row">
|
||||
<!-- Left: Area tree -->
|
||||
<Col :xs="24" :sm="24" :md="6" :lg="5" :xl="4" class="tree-col">
|
||||
<Card class="tree-card" title="业务区域">
|
||||
<AreaTree ref="areaTreeRef" @select="handleAreaSelect" />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<!-- Right: Staff list -->
|
||||
<Col :xs="24" :sm="24" :md="18" :lg="19" :xl="20">
|
||||
<Spin :spinning="loading">
|
||||
<!-- Header -->
|
||||
<div class="content-header mb-3">
|
||||
<div class="content-header__left">
|
||||
<span class="content-title">
|
||||
{{ selectedAreaPath || '请选择区域' }}
|
||||
</span>
|
||||
<span v-if="selectedArea" class="content-subtitle">
|
||||
- 安保人员 ({{ displayList.length }})
|
||||
</span>
|
||||
</div>
|
||||
<Button v-if="selectedArea" type="primary" @click="handleAddStaff">
|
||||
+ 新增绑定
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty: no area selected -->
|
||||
<Card v-if="!selectedArea" class="empty-card">
|
||||
<Empty description="请在左侧选择一个区域以查看安保人员" />
|
||||
</Card>
|
||||
|
||||
<!-- Empty: no staff -->
|
||||
<Card
|
||||
v-else-if="displayList.length === 0 && !loading"
|
||||
class="empty-card"
|
||||
>
|
||||
<Empty description="该区域暂无绑定安保人员">
|
||||
<Button type="primary" @click="handleAddStaff"> 新增绑定 </Button>
|
||||
</Empty>
|
||||
</Card>
|
||||
|
||||
<!-- Staff cards -->
|
||||
<template v-else>
|
||||
<!-- Direct -->
|
||||
<div v-if="directList.length > 0" class="section">
|
||||
<div class="section__title">直接绑定 ({{ directList.length }})</div>
|
||||
<Row :gutter="[12, 12]">
|
||||
<Col
|
||||
v-for="item in directList"
|
||||
:key="item.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="12"
|
||||
:lg="8"
|
||||
:xl="6"
|
||||
>
|
||||
<StaffCard :item="item" @unbind="handleUnbind" />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<!-- Inherited -->
|
||||
<div v-if="inheritedList.length > 0" class="section">
|
||||
<div class="section__title">
|
||||
继承人员 ({{ inheritedList.length }})
|
||||
<span class="section__hint">
|
||||
— 来自父级区域,如需修改请前往对应源区域
|
||||
</span>
|
||||
</div>
|
||||
<Row :gutter="[12, 12]">
|
||||
<Col
|
||||
v-for="item in inheritedList"
|
||||
:key="`${item.id}-${item.sourceAreaId}`"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="12"
|
||||
:lg="8"
|
||||
:xl="6"
|
||||
>
|
||||
<StaffCard :item="item" @go-source="handleGoSource" />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</template>
|
||||
</Spin>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- Modal -->
|
||||
<BindModal @success="refreshList" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@media (max-width: 768px) {
|
||||
.layout-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tree-col {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tree-card {
|
||||
:deep(.ant-card-body) {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tree-col {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tree-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
|
||||
.ant-card-head-title {
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
:deep(.ant-tree) {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
.content-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.content-subtitle {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
border-radius: 8px;
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&__title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.content-title {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.content-subtitle {
|
||||
color: rgb(255 255 255 / 45%);
|
||||
}
|
||||
|
||||
.section__title {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.section__hint {
|
||||
color: rgb(255 255 255 / 45%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { bindAreaSecurityUser } from '#/api/ops/area-security';
|
||||
import { getSimpleUserList } from '#/api/system/user';
|
||||
|
||||
const emit = defineEmits<{ (e: 'success'): void }>();
|
||||
|
||||
const areaId = ref(0);
|
||||
const areaPath = ref('');
|
||||
/** 当前区域已绑定的 userId 集合(含直接+继承) */
|
||||
const boundUserIds = ref<number[]>([]);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
fieldName: 'areaPath',
|
||||
label: '绑定区域',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'userId',
|
||||
label: '安保人员',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleUserList,
|
||||
labelField: 'nickname',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择安保人员',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
areaId: number;
|
||||
areaPath: string;
|
||||
boundUserIds?: number[];
|
||||
}>();
|
||||
if (data) {
|
||||
areaId.value = data.areaId;
|
||||
areaPath.value = data.areaPath;
|
||||
boundUserIds.value = data.boundUserIds ?? [];
|
||||
}
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues({ areaPath: areaPath.value });
|
||||
},
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
|
||||
const values = await formApi.getValues();
|
||||
|
||||
// 校验:该人员是否已绑定此区域(含继承)
|
||||
if (boundUserIds.value.includes(values.userId)) {
|
||||
message.warning(
|
||||
'该人员已绑定此区域(直接绑定或从上级区域继承),无需重复绑定',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.lock();
|
||||
try {
|
||||
await bindAreaSecurityUser({
|
||||
areaId: areaId.value,
|
||||
userId: values.userId,
|
||||
});
|
||||
message.success('绑定成功');
|
||||
emit('success');
|
||||
await modalApi.close();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal title="绑定安保人员">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
179
apps/web-antd/src/views/ops/area-security/modules/staff-card.vue
Normal file
179
apps/web-antd/src/views/ops/area-security/modules/staff-card.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import type { AreaSecurityUserDisplay } from '../data';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button, Card, Tag } from 'ant-design-vue';
|
||||
|
||||
import { BIND_TYPE_MAP } from '../data';
|
||||
|
||||
const props = defineProps<{
|
||||
item: AreaSecurityUserDisplay;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'goSource', item: AreaSecurityUserDisplay): void;
|
||||
(e: 'unbind', item: AreaSecurityUserDisplay): void;
|
||||
}>();
|
||||
|
||||
const isDirect = computed(() => props.item.bindType === 'DIRECT');
|
||||
const bindMeta = computed(() => BIND_TYPE_MAP[props.item.bindType]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
class="staff-card"
|
||||
:class="{ 'staff-card--inherited': !isDirect }"
|
||||
hoverable
|
||||
:body-style="{ padding: '0' }"
|
||||
>
|
||||
<div class="staff-card__body">
|
||||
<!-- Header -->
|
||||
<div class="staff-card__header">
|
||||
<span class="staff-card__name" :title="item.userName">
|
||||
{{ item.userName || '--' }}
|
||||
</span>
|
||||
<Tag :color="bindMeta.color" class="staff-card__tag">
|
||||
{{ bindMeta.label }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="staff-card__info">
|
||||
<div v-if="item.createTime" class="staff-card__info-row">
|
||||
<span class="staff-card__info-label">绑定时间</span>
|
||||
<span class="staff-card__info-value">{{ item.createTime }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isDirect && item.sourceAreaName"
|
||||
class="staff-card__info-row"
|
||||
>
|
||||
<span class="staff-card__info-label">来源区域</span>
|
||||
<span class="staff-card__info-value staff-card__source">
|
||||
{{ item.sourceAreaName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="staff-card__footer">
|
||||
<Tag v-if="!item.enabled" color="error">已停用</Tag>
|
||||
<Button
|
||||
v-if="isDirect"
|
||||
size="small"
|
||||
danger
|
||||
@click="emit('unbind', item)"
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
size="small"
|
||||
type="link"
|
||||
class="staff-card__go-source"
|
||||
@click="emit('goSource', item)"
|
||||
>
|
||||
去源区域查看
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.staff-card {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&--inherited {
|
||||
opacity: 0.75;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
flex-shrink: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&__info-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__info-label {
|
||||
flex-shrink: 0;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
&__info-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #595959;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__source {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__go-source {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.staff-card__name {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.staff-card__info-label {
|
||||
color: rgb(255 255 255 / 45%);
|
||||
}
|
||||
|
||||
.staff-card__info-value {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
606
apps/web-antd/src/views/ops/area-security/modules/staff-view.vue
Normal file
606
apps/web-antd/src/views/ops/area-security/modules/staff-view.vue
Normal file
@@ -0,0 +1,606 @@
|
||||
<script setup lang="ts">
|
||||
import type { OpsAreaApi } from '#/api/ops/area';
|
||||
import type { AreaSecurityApi } from '#/api/ops/area-security';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Row,
|
||||
Spin,
|
||||
Tag,
|
||||
Tree,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getAreaTree } from '#/api/ops/area';
|
||||
import {
|
||||
getAreaSecurityUserList,
|
||||
unbindAreaSecurityUser,
|
||||
} from '#/api/ops/area-security';
|
||||
|
||||
import { BIND_TYPE_MAP } from '../data';
|
||||
|
||||
// ========== Load all data upfront ==========
|
||||
const areaFlatList = ref<OpsAreaApi.BusArea[]>([]);
|
||||
const areaTreeData = ref<any[]>([]);
|
||||
const globalLoading = ref(false);
|
||||
|
||||
/**
|
||||
* 全量绑定数据:areaId → AreaSecurityUser[]
|
||||
* 一次性加载所有区域的绑定,避免人员视角需要逐个请求
|
||||
*/
|
||||
const allBindingsMap = ref<Map<number, AreaSecurityApi.AreaSecurityUser[]>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
/**
|
||||
* 所有出现过的安保人员(去重),用于左侧列表
|
||||
*/
|
||||
interface StaffSummary {
|
||||
userId: number;
|
||||
userName: string;
|
||||
/** 直接绑定的区域数 */
|
||||
directCount: number;
|
||||
}
|
||||
|
||||
const staffSummaryList = ref<StaffSummary[]>([]);
|
||||
|
||||
async function loadAllData() {
|
||||
globalLoading.value = true;
|
||||
try {
|
||||
// 1. 加载区域树
|
||||
const areas = await getAreaTree({ isActive: true });
|
||||
areaFlatList.value = areas;
|
||||
areaTreeData.value = handleTree(areas, 'id', 'parentId', 'children');
|
||||
|
||||
// 2. 批量加载所有区域的绑定
|
||||
const map = new Map<number, AreaSecurityApi.AreaSecurityUser[]>();
|
||||
const userMap = new Map<number, StaffSummary>();
|
||||
|
||||
// 并发加载(限制并发数)
|
||||
const areaIds = areas.map((a) => a.id!).filter(Boolean);
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < areaIds.length; i += batchSize) {
|
||||
const batch = areaIds.slice(i, i + batchSize);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (areaId) => {
|
||||
const list = await getAreaSecurityUserList(areaId);
|
||||
return { areaId, list };
|
||||
}),
|
||||
);
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
map.set(r.value.areaId, r.value.list);
|
||||
for (const binding of r.value.list) {
|
||||
if (!binding.enabled) continue;
|
||||
const existing = userMap.get(binding.userId);
|
||||
if (existing) {
|
||||
existing.directCount++;
|
||||
} else {
|
||||
userMap.set(binding.userId, {
|
||||
userId: binding.userId,
|
||||
userName: binding.userName,
|
||||
directCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allBindingsMap.value = map;
|
||||
staffSummaryList.value = [...userMap.values()].toSorted(
|
||||
(a, b) => b.directCount - a.directCount,
|
||||
);
|
||||
} catch {
|
||||
areaFlatList.value = [];
|
||||
areaTreeData.value = [];
|
||||
allBindingsMap.value = new Map();
|
||||
staffSummaryList.value = [];
|
||||
} finally {
|
||||
globalLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
loadAllData();
|
||||
|
||||
// ========== Search / filter staff ==========
|
||||
const staffSearchValue = ref('');
|
||||
|
||||
const filteredStaffList = computed(() => {
|
||||
const kw = staffSearchValue.value.trim().toLowerCase();
|
||||
if (!kw) return staffSummaryList.value;
|
||||
return staffSummaryList.value.filter((s) =>
|
||||
s.userName.toLowerCase().includes(kw),
|
||||
);
|
||||
});
|
||||
|
||||
// ========== Selected staff ==========
|
||||
const selectedStaff = ref<null | StaffSummary>(null);
|
||||
|
||||
function handleStaffSelect(staff: StaffSummary) {
|
||||
selectedStaff.value = staff;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中人员直接绑定的区域 ID 集合
|
||||
*/
|
||||
const directAreaIds = computed(() => {
|
||||
if (!selectedStaff.value) return new Set<number>();
|
||||
const set = new Set<number>();
|
||||
for (const [areaId, bindings] of allBindingsMap.value) {
|
||||
for (const b of bindings) {
|
||||
if (b.userId === selectedStaff.value.userId && b.enabled) {
|
||||
set.add(areaId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return set;
|
||||
});
|
||||
|
||||
/**
|
||||
* 继承的区域 ID 集合:
|
||||
* 如果某区域的祖先在 directAreaIds 中,该区域算继承
|
||||
*/
|
||||
const inheritedAreaIds = computed(() => {
|
||||
const direct = directAreaIds.value;
|
||||
if (direct.size === 0) return new Set<number>();
|
||||
|
||||
const areaMap = new Map(areaFlatList.value.map((a) => [a.id, a]));
|
||||
const set = new Set<number>();
|
||||
|
||||
for (const area of areaFlatList.value) {
|
||||
if (!area.id || direct.has(area.id)) continue;
|
||||
// 向上查找祖先
|
||||
let cur = area.parentId ? areaMap.get(area.parentId) : undefined;
|
||||
while (cur) {
|
||||
if (direct.has(cur.id!)) {
|
||||
set.add(area.id);
|
||||
break;
|
||||
}
|
||||
cur = cur.parentId ? areaMap.get(cur.parentId) : undefined;
|
||||
}
|
||||
}
|
||||
return set;
|
||||
});
|
||||
|
||||
const allBoundAreaIds = computed(
|
||||
() => new Set([...directAreaIds.value, ...inheritedAreaIds.value]),
|
||||
);
|
||||
|
||||
/** 根据 areaId 找到该人员在此区域的绑定记录(用于解绑) */
|
||||
function findBindingRecord(areaId: number) {
|
||||
const bindings = allBindingsMap.value.get(areaId) ?? [];
|
||||
return bindings.find(
|
||||
(b) => b.userId === selectedStaff.value?.userId && b.enabled,
|
||||
);
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadAllData();
|
||||
}
|
||||
|
||||
// ========== Unbind ==========
|
||||
function handleUnbind(areaId: number, areaName: string) {
|
||||
if (!selectedStaff.value) return;
|
||||
const record = findBindingRecord(areaId);
|
||||
if (!record) return;
|
||||
const staffName = selectedStaff.value.userName;
|
||||
Modal.confirm({
|
||||
title: '确认解除绑定',
|
||||
content: `确定要解除「${staffName}」在区域「${areaName}」的绑定吗?`,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
await unbindAreaSecurityUser(record.id);
|
||||
message.success('已解除绑定');
|
||||
refreshAll();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Spin :spinning="globalLoading">
|
||||
<Row :gutter="12" class="layout-row">
|
||||
<!-- Left: Staff list -->
|
||||
<Col :xs="24" :sm="24" :md="6" :lg="5" :xl="4" class="staff-col">
|
||||
<Card class="staff-panel" title="安保人员">
|
||||
<div class="staff-search">
|
||||
<Input
|
||||
v-model:value="staffSearchValue"
|
||||
placeholder="搜索人员"
|
||||
allow-clear
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<IconifyIcon icon="lucide:search" class="size-4" />
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div v-if="filteredStaffList.length > 0" class="staff-list">
|
||||
<div
|
||||
v-for="staff in filteredStaffList"
|
||||
:key="staff.userId"
|
||||
class="staff-list-item"
|
||||
:class="{
|
||||
'staff-list-item--active':
|
||||
selectedStaff?.userId === staff.userId,
|
||||
}"
|
||||
@click="handleStaffSelect(staff)"
|
||||
>
|
||||
<div class="staff-list-item__title">
|
||||
<span>{{ staff.userName }}</span>
|
||||
<Badge
|
||||
v-if="staff.directCount > 0"
|
||||
:count="staff.directCount"
|
||||
:number-style="{
|
||||
backgroundColor: '#1677ff',
|
||||
fontSize: '11px',
|
||||
minWidth: '18px',
|
||||
height: '18px',
|
||||
lineHeight: '18px',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!globalLoading"
|
||||
class="py-4 text-center text-gray-500"
|
||||
>
|
||||
暂无人员数据
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<!-- Right: Area tree with highlights -->
|
||||
<Col :xs="24" :sm="24" :md="18" :lg="19" :xl="20">
|
||||
<!-- Header -->
|
||||
<div class="content-header mb-3">
|
||||
<div class="content-header__left">
|
||||
<span class="content-title">
|
||||
{{ selectedStaff?.userName || '请选择人员' }}
|
||||
</span>
|
||||
<span v-if="selectedStaff" class="content-subtitle">
|
||||
- 管辖区域 ({{ allBoundAreaIds.size }})
|
||||
</span>
|
||||
</div>
|
||||
<!-- Legend -->
|
||||
<div v-if="selectedStaff" class="legend">
|
||||
<span class="legend__item">
|
||||
<span class="legend__dot legend__dot--direct"></span>
|
||||
直接绑定
|
||||
</span>
|
||||
<span class="legend__item">
|
||||
<span class="legend__dot legend__dot--inherited"></span>
|
||||
继承
|
||||
</span>
|
||||
<span class="legend__item">
|
||||
<span class="legend__dot legend__dot--none"></span>
|
||||
未管辖
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty: no staff selected -->
|
||||
<Card v-if="!selectedStaff" class="empty-card">
|
||||
<Empty description="请在左侧选择一位安保人员以查看其管辖区域" />
|
||||
</Card>
|
||||
|
||||
<!-- Area tree -->
|
||||
<Card v-else class="tree-content-card">
|
||||
<Tree
|
||||
v-if="areaTreeData.length > 0"
|
||||
:tree-data="areaTreeData"
|
||||
:default-expand-all="true"
|
||||
:selectable="false"
|
||||
:field-names="{
|
||||
title: 'areaName',
|
||||
key: 'id',
|
||||
children: 'children',
|
||||
}"
|
||||
>
|
||||
<template #title="{ dataRef }">
|
||||
<div
|
||||
class="tree-node"
|
||||
:class="{
|
||||
'tree-node--direct': directAreaIds.has(dataRef.id),
|
||||
'tree-node--inherited': inheritedAreaIds.has(dataRef.id),
|
||||
'tree-node--none': !allBoundAreaIds.has(dataRef.id),
|
||||
}"
|
||||
>
|
||||
<span class="tree-node__name">{{ dataRef.areaName }}</span>
|
||||
|
||||
<!-- Direct: tag + unbind -->
|
||||
<template v-if="directAreaIds.has(dataRef.id)">
|
||||
<Tag color="blue" class="tree-node__tag">
|
||||
{{ BIND_TYPE_MAP.DIRECT.label }}
|
||||
</Tag>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
class="tree-node__action"
|
||||
@click.stop="handleUnbind(dataRef.id, dataRef.areaName)"
|
||||
>
|
||||
解绑
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Inherited: tag only -->
|
||||
<Tag
|
||||
v-else-if="inheritedAreaIds.has(dataRef.id)"
|
||||
class="tree-node__tag"
|
||||
:color="BIND_TYPE_MAP.INHERITED.color"
|
||||
>
|
||||
{{ BIND_TYPE_MAP.INHERITED.label }}
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
</Tree>
|
||||
<div
|
||||
v-else-if="!globalLoading"
|
||||
class="py-4 text-center text-gray-500"
|
||||
>
|
||||
暂无区域数据
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Spin>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@media (max-width: 768px) {
|
||||
.layout-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.staff-col {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.staff-panel {
|
||||
:deep(.ant-card-body) {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.staff-col {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.staff-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
|
||||
.ant-card-head-title {
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.staff-search {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.staff-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.staff-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #1677ff;
|
||||
background: #e6f4ff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
.content-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.content-subtitle {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
&--direct {
|
||||
background: #1677ff;
|
||||
}
|
||||
|
||||
&--inherited {
|
||||
background: #91caff;
|
||||
}
|
||||
|
||||
&--none {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
border-radius: 8px;
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-content-card {
|
||||
border-radius: 8px;
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-tree) {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&--direct {
|
||||
font-weight: 600;
|
||||
color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
&--inherited {
|
||||
color: #595959;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&--none {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
&__name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
margin-right: 0;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&__action {
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.content-title {
|
||||
color: rgb(255 255 255 / 85%);
|
||||
}
|
||||
|
||||
.content-subtitle {
|
||||
color: rgb(255 255 255 / 45%);
|
||||
}
|
||||
|
||||
.tree-node--direct {
|
||||
color: #69b1ff;
|
||||
background: rgb(22 119 255 / 15%);
|
||||
}
|
||||
|
||||
.tree-node--inherited {
|
||||
color: rgb(255 255 255 / 65%);
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.tree-node--none {
|
||||
color: rgb(255 255 255 / 25%);
|
||||
}
|
||||
|
||||
.staff-list-item:hover {
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.staff-list-item--active {
|
||||
background: rgb(22 119 255 / 15%) !important;
|
||||
}
|
||||
|
||||
.legend {
|
||||
color: rgb(255 255 255 / 45%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user