- 新增区域安保 API 接口定义 - 新增区域安保配置页面,支持区域视图和人员视图 - 包含人员绑定弹窗和人员卡片组件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
357 lines
8.0 KiB
Vue
357 lines
8.0 KiB
Vue
<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>
|