Files
aiot-platform-ui/apps/web-antd/src/views/ops/area-security/modules/area-view.vue

357 lines
8.0 KiB
Vue
Raw Normal View History

<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>