Files
aiot-platform-ui/apps/web-antd/src/views/ops/area-security/modules/area-view.vue
lzh b9f45c8fdc feat(@vben/web-antd): 新增区域安保配置模块
- 新增区域安保 API 接口定义
- 新增区域安保配置页面,支持区域视图和人员视图
- 包含人员绑定弹窗和人员卡片组件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:54:38 +08:00

357 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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