feat(@vben/web-antd): 新增区域安保配置模块

- 新增区域安保 API 接口定义
- 新增区域安保配置页面,支持区域视图和人员视图
- 包含人员绑定弹窗和人员卡片组件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-15 16:54:38 +08:00
parent b1db581d36
commit b9f45c8fdc
7 changed files with 1425 additions and 0 deletions

View 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 },
});
}

View 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;
}

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

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

View File

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

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

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