feat(@vben/web-antd): 新增区域管理模块视图

- 新建区域管理页面目录结构
- 支持区域层级管理(建筑/楼层/功能区)
- 设备绑定抽屉:支持区域内IoT设备管理
- 区域表单:基础版本和增强版本
- 设备配置模态框和选择模态框
- 修复ESLint警告:使用严格相等运算符

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-02-03 22:43:25 +08:00
parent 38a6eaa39e
commit ce3e57e398
10 changed files with 3092 additions and 0 deletions

View File

@@ -0,0 +1,632 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type {
AreaLevel,
AreaType,
FunctionType,
OpsAreaApi,
RelationType,
} from '#/api/ops/area';
import { h } from 'vue';
import { handleTree } from '@vben/utils';
import { Badge, Tag } from 'ant-design-vue';
import { z } from '#/adapter/form';
import { getSimpleDeviceList } from '#/api/iot/device/device';
import { getAreaTree } from '#/api/ops/area';
/** 区域类型选项 */
export const AREA_TYPE_OPTIONS: {
color: string;
icon: string;
label: string;
value: AreaType;
}[] = [
{ label: '园区', value: 'PARK', color: 'blue', icon: '🏢' },
{ label: '楼栋', value: 'BUILDING', color: 'cyan', icon: '🏛️' },
{ label: '楼层', value: 'FLOOR', color: 'geekblue', icon: '📐' },
{ label: '功能区域', value: 'FUNCTION', color: 'purple', icon: '🎯' },
];
/** 功能类型选项 */
export const FUNCTION_TYPE_OPTIONS: {
color: string;
icon: string;
label: string;
value: FunctionType;
}[] = [
{ label: '男厕', value: 'MALE_TOILET', color: 'blue', icon: '🚹' },
{ label: '女厕', value: 'FEMALE_TOILET', color: 'magenta', icon: '🚺' },
{ label: '公共区域', value: 'PUBLIC', color: 'green', icon: '🌍' },
{ label: '电梯', value: 'ELEVATOR', color: 'orange', icon: '🛗' },
];
/** 区域等级选项 */
export const AREA_LEVEL_OPTIONS: {
color: string;
label: string;
value: AreaLevel;
}[] = [
{ label: '高', value: 'HIGH', color: 'red' },
{ label: '中', value: 'MEDIUM', color: 'orange' },
{ label: '低', value: 'LOW', color: 'green' },
];
/** 关联类型选项 */
export const RELATION_TYPE_OPTIONS: {
color: string;
icon: string;
label: string;
value: RelationType;
}[] = [
{ label: '客流计数', value: 'TRAFFIC_COUNTER', color: 'blue', icon: '👥' },
{ label: '信标', value: 'BEACON', color: 'purple', icon: '📡' },
{ label: '工牌', value: 'BADGE', color: 'orange', icon: '🎫' },
];
/** 启用状态选项(筛选用) */
export const ACTIVE_OPTIONS = [
{ label: '全部', value: undefined },
{ label: '启用', value: true },
{ label: '停用', value: false },
];
/** 列表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '区域名称/编码',
component: 'Input',
componentProps: {
placeholder: '请输入区域名称或编码',
allowClear: true,
},
},
{
fieldName: 'areaType',
label: '区域类型',
component: 'Select',
componentProps: {
placeholder: '请选择区域类型',
allowClear: true,
options: AREA_TYPE_OPTIONS,
},
},
{
fieldName: 'isActive',
label: '启用状态',
component: 'Select',
componentProps: {
placeholder: '请选择',
allowClear: true,
options: [
{ label: '启用', value: true },
{ label: '停用', value: false },
],
},
},
];
}
/** 区域新增/编辑表单 schema */
export function useAreaFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: { triggerFields: [''], show: () => false },
},
{
fieldName: 'parentId',
label: '上级区域',
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: async () => {
const data = await getAreaTree();
const list = (
Array.isArray(data) ? data : ((data as any)?.list ?? [])
) as any[];
list.unshift({
id: 0,
areaName: '根节点',
parentId: undefined,
});
return handleTree(list, 'id', 'parentId');
},
labelField: 'areaName',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级区域',
treeDefaultExpandAll: true,
},
dependencies: {
triggerFields: ['id', 'parentId'],
disabled: (values) =>
!!(
values?.parentId !== null &&
values?.parentId !== 0 &&
!values?.id
),
},
rules: 'selectRequired',
},
{
fieldName: 'areaName',
label: '区域名称',
component: 'Input',
componentProps: { placeholder: '请输入区域名称' },
rules: 'required',
},
{
fieldName: 'areaCode',
label: '区域编码',
component: 'Input',
componentProps: { placeholder: '请输入区域编码' },
},
{
fieldName: 'areaType',
label: '区域类型',
component: 'Select',
componentProps: {
placeholder: '请选择区域类型',
options: AREA_TYPE_OPTIONS,
},
rules: 'selectRequired',
},
{
fieldName: 'functionType',
label: '功能类型',
component: 'Select',
componentProps: {
placeholder: '请选择功能类型',
allowClear: true,
options: FUNCTION_TYPE_OPTIONS,
},
},
{
fieldName: 'floorNo',
label: '楼层号',
component: 'InputNumber',
componentProps: {
min: -10,
placeholder: '请输入楼层号',
class: 'w-full',
},
},
{
fieldName: 'cleaningFrequency',
label: '保洁频率',
component: 'Input',
componentProps: { placeholder: '如:每日 2 次' },
},
{
fieldName: 'standardDuration',
label: '标准时长(分钟)',
component: 'InputNumber',
componentProps: {
min: 0,
placeholder: '请输入标准时长',
class: 'w-full',
},
},
{
fieldName: 'areaLevel',
label: '区域等级',
component: 'Select',
componentProps: {
placeholder: '请选择区域等级',
allowClear: true,
options: AREA_LEVEL_OPTIONS,
},
},
{
fieldName: 'isActive',
label: '启用状态',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '启用', value: true },
{ label: '停用', value: false },
],
optionType: 'button',
buttonStyle: 'solid',
},
rules: z.boolean().default(true),
},
{
fieldName: 'sort',
label: '排序',
component: 'InputNumber',
componentProps: {
min: 0,
placeholder: '请输入排序',
class: 'w-full',
},
rules: z.number().min(0).default(0),
},
];
}
/** 区域列表列 - 增强版 */
export function useGridColumns(): VxeTableGridOptions<OpsAreaApi.BusArea>['columns'] {
return [
{
field: 'areaName',
title: '区域名称',
minWidth: 200,
align: 'left',
fixed: 'left',
treeNode: true,
slots: {
default: ({ row }: { row: OpsAreaApi.BusArea }) => {
return h('div', { class: 'flex items-center gap-2' }, [
h(
'span',
{ class: 'font-medium text-gray-900 dark:text-white' },
row.areaName,
),
]);
},
},
},
{
field: 'areaCode',
title: '区域编码',
minWidth: 120,
slots: {
default: ({ row }: { row: OpsAreaApi.BusArea }) => {
return row.areaCode
? h(
'code',
{
class:
'px-2 py-1 text-xs bg-gray-100 dark:bg-gray-800 rounded',
},
row.areaCode,
)
: h('span', { class: 'text-gray-400' }, '-');
},
},
},
{
field: 'areaType',
title: '区域类型',
minWidth: 120,
slots: {
default: ({ row }: { row: OpsAreaApi.BusArea }) => {
const option = AREA_TYPE_OPTIONS.find(
(o) => o.value === row.areaType,
);
return option
? h(
Tag,
{ color: option.color },
() => `${option.icon} ${option.label}`,
)
: h('span', row.areaType || '-');
},
},
},
{
field: 'functionType',
title: '功能类型',
minWidth: 120,
slots: {
default: ({ row }: { row: OpsAreaApi.BusArea }) => {
const option = FUNCTION_TYPE_OPTIONS.find(
(o) => o.value === row.functionType,
);
return option
? h(
Tag,
{ color: option.color },
() => `${option.icon} ${option.label}`,
)
: h('span', { class: 'text-gray-400' }, '-');
},
},
},
{
field: 'floorNo',
title: '楼层号',
minWidth: 88,
align: 'center',
slots: {
default: ({ row }: { row: OpsAreaApi.BusArea }) => {
return row.floorNo === null
? h('span', { class: 'text-gray-400' }, '-')
: h(
'span',
{
class:
'inline-flex items-center justify-center px-2 py-1 bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300 rounded-md font-medium',
},
`${row.floorNo}F`,
);
},
},
},
{
field: 'cleaningFrequency',
title: '保洁频率',
minWidth: 120,
slots: {
default: ({ row }: { row: OpsAreaApi.BusArea }) => {
return row.cleaningFrequency
? h(
'span',
{ class: 'text-gray-700 dark:text-gray-300' },
row.cleaningFrequency,
)
: h('span', { class: 'text-gray-400' }, '-');
},
},
},
{
field: 'standardDuration',
title: '标准时长',
minWidth: 110,
align: 'center',
slots: {
default: ({ row }: { row: OpsAreaApi.BusArea }) => {
return row.standardDuration === null
? h('span', { class: 'text-gray-400' }, '-')
: h(
'span',
{
class:
'inline-flex items-center gap-1 px-2 py-1 bg-purple-50 dark:bg-purple-950/30 text-purple-700 dark:text-purple-300 rounded-md',
},
[
h(
'svg',
{
xmlns: 'http://www.w3.org/2000/svg',
class: 'h-4 w-4',
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor',
},
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
}),
],
),
h('span', `${row.standardDuration}分钟`),
],
);
},
},
},
{
field: 'areaLevel',
title: '区域等级',
minWidth: 100,
align: 'center',
slots: {
default: ({ row }: { row: OpsAreaApi.BusArea }) => {
const option = AREA_LEVEL_OPTIONS.find(
(o) => o.value === row.areaLevel,
);
return option
? h(
Badge,
{
status:
option.color === 'red' ? 'error' : (
option.color === 'orange'
? 'warning'
: 'success'
),
text: option.label,
},
)
: h('span', { class: 'text-gray-400' }, '-');
},
},
},
{
field: 'isActive',
title: '状态',
minWidth: 90,
align: 'center',
slots: {
default: ({ row }: { row: OpsAreaApi.BusArea }) => {
return h(Badge, {
status: row.isActive ? 'success' : 'error',
text: row.isActive ? '启用' : '停用',
});
},
},
},
{
field: 'sort',
title: '排序',
minWidth: 72,
align: 'center',
slots: {
default: ({ row }: { row: OpsAreaApi.BusArea }) => {
return h(
'span',
{ class: 'text-gray-600 dark:text-gray-400 font-mono' },
row.sort ?? 0,
);
},
},
},
{
title: '操作',
width: 360,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 绑定设备表单 schema */
export function useBindFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'deviceId',
label: '设备',
component: 'ApiSelect',
componentProps: {
api: () => getSimpleDeviceList(),
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择设备',
},
rules: 'selectRequired',
},
{
fieldName: 'relationType',
label: '关联类型',
component: 'Select',
componentProps: {
placeholder: '请选择关联类型',
options: RELATION_TYPE_OPTIONS,
},
rules: 'selectRequired',
},
];
}
/** 已绑定设备列表列 */
export function useDeviceRelationColumns(): VxeTableGridOptions<OpsAreaApi.AreaDeviceRelation>['columns'] {
return [
{
field: 'deviceName',
title: '设备名称',
minWidth: 140,
formatter: ({ row }) => row?.deviceName || row?.deviceKey || '-',
},
{ field: 'deviceKey', title: '设备 Key', minWidth: 140 },
{
field: 'productName',
title: '产品',
minWidth: 120,
formatter: ({ cellValue, row }) => cellValue || row?.productKey || '-',
},
{
field: 'relationType',
title: '关联类型',
minWidth: 100,
formatter: ({ cellValue }) =>
RELATION_TYPE_OPTIONS.find((o) => o.value === cellValue)?.label ??
cellValue ??
'-',
},
{
field: 'enabled',
title: '状态',
minWidth: 84,
formatter: ({ cellValue }) => (cellValue ? '启用' : '停用'),
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'device-actions' },
},
];
}
/** 按 relation_type 获取保洁配置表单 schema */
export function useDeviceConfigFormSchema(
relationType: RelationType,
): VbenFormSchema[] {
switch (relationType) {
case 'BADGE': {
return [
{
fieldName: 'notifyType',
label: '通知方式',
component: 'Select',
componentProps: {
options: [
{ label: '震动', value: 'VIBRATE' },
{ label: '语音', value: 'VOICE' },
],
},
},
{
fieldName: 'voiceTemplate',
label: '语音内容模板',
component: 'Input',
componentProps: { placeholder: '语音播报内容模板' },
},
{
fieldName: 'batteryAlertThreshold',
label: '电量告警阈值(%)',
component: 'InputNumber',
componentProps: {
min: 0,
max: 100,
placeholder: '低于该值告警',
class: 'w-full',
},
},
];
}
case 'BEACON': {
return [
{
fieldName: 'rssiThreshold',
label: 'RSSI 阈值',
component: 'InputNumber',
componentProps: { placeholder: '如 -70', class: 'w-full' },
},
{
fieldName: 'attendanceRule',
label: '到岗判定规则',
component: 'Input',
componentProps: { placeholder: '简要描述到岗判定规则' },
},
];
}
case 'TRAFFIC_COUNTER': {
return [
{
fieldName: 'trafficThreshold',
label: '客流阈值',
component: 'InputNumber',
componentProps: {
min: 1,
placeholder: '超过该人数触发',
class: 'w-full',
},
},
{
fieldName: 'statPeriodMinutes',
label: '统计周期(分钟)',
component: 'InputNumber',
componentProps: { min: 1, placeholder: '统计周期', class: 'w-full' },
},
{
fieldName: 'triggerAction',
label: '触发动作',
component: 'Select',
componentProps: {
placeholder: '请选择',
options: [
{ label: '生成工单', value: 'CREATE_ORDER' },
{ label: '仅告警', value: 'ALERT_ONLY' },
],
},
},
];
}
default: {
return [];
}
}
}

View File

@@ -0,0 +1,458 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type {
AreaLevel,
AreaType,
FunctionType,
OpsAreaApi,
RelationType,
} from '#/api/ops/area';
import { handleTree } from '@vben/utils';
import { z } from '#/adapter/form';
import { getSimpleDeviceList } from '#/api/iot/device/device';
import { getAreaTree } from '#/api/ops/area';
/** 区域类型选项 */
export const AREA_TYPE_OPTIONS: { label: string; value: AreaType }[] = [
{ label: '园区', value: 'PARK' },
{ label: '楼栋', value: 'BUILDING' },
{ label: '楼层', value: 'FLOOR' },
{ label: '功能区域', value: 'FUNCTION' },
];
/** 功能类型选项 */
export const FUNCTION_TYPE_OPTIONS: { label: string; value: FunctionType }[] = [
{ label: '男厕', value: 'MALE_TOILET' },
{ label: '女厕', value: 'FEMALE_TOILET' },
{ label: '公共区域', value: 'PUBLIC' },
{ label: '电梯', value: 'ELEVATOR' },
];
/** 区域等级选项 */
export const AREA_LEVEL_OPTIONS: { label: string; value: AreaLevel }[] = [
{ label: '高', value: 'HIGH' },
{ label: '中', value: 'MEDIUM' },
{ label: '低', value: 'LOW' },
];
/** 关联类型选项 */
export const RELATION_TYPE_OPTIONS: { label: string; value: RelationType }[] = [
{ label: '客流计数', value: 'TRAFFIC_COUNTER' },
{ label: '信标', value: 'BEACON' },
{ label: '工牌', value: 'BADGE' },
];
/** 启用状态选项(筛选用) */
export const ACTIVE_OPTIONS = [
{ label: '全部', value: undefined },
{ label: '启用', value: true },
{ label: '停用', value: false },
];
/** 列表搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '区域名称/编码',
component: 'Input',
componentProps: {
placeholder: '请输入区域名称或编码',
allowClear: true,
},
},
{
fieldName: 'areaType',
label: '区域类型',
component: 'Select',
componentProps: {
placeholder: '请选择区域类型',
allowClear: true,
options: AREA_TYPE_OPTIONS,
},
},
{
fieldName: 'isActive',
label: '启用状态',
component: 'Select',
componentProps: {
placeholder: '请选择',
allowClear: true,
options: [
{ label: '启用', value: true },
{ label: '停用', value: false },
],
},
},
];
}
/** 区域新增/编辑表单 schema */
export function useAreaFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: { triggerFields: [''], show: () => false },
},
{
fieldName: 'parentId',
label: '上级区域',
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: async () => {
const data = await getAreaTree();
const list = (
Array.isArray(data) ? data : ((data as any)?.list ?? [])
) as any[];
list.unshift({
id: 0,
areaName: '根节点',
parentId: undefined,
});
return handleTree(list, 'id', 'parentId');
},
labelField: 'areaName',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级区域',
treeDefaultExpandAll: true,
},
dependencies: {
triggerFields: ['id', 'parentId'],
disabled: (values) =>
!!(
values?.parentId !== null &&
values?.parentId !== 0 &&
!values?.id
),
},
rules: 'selectRequired',
},
{
fieldName: 'areaName',
label: '区域名称',
component: 'Input',
componentProps: { placeholder: '请输入区域名称' },
rules: 'required',
},
{
fieldName: 'areaCode',
label: '区域编码',
component: 'Input',
componentProps: { placeholder: '请输入区域编码' },
},
{
fieldName: 'areaType',
label: '区域类型',
component: 'Select',
componentProps: {
placeholder: '请选择区域类型',
options: AREA_TYPE_OPTIONS,
},
rules: 'selectRequired',
},
{
fieldName: 'functionType',
label: '功能类型',
component: 'Select',
componentProps: {
placeholder: '请选择功能类型',
allowClear: true,
options: FUNCTION_TYPE_OPTIONS,
},
},
{
fieldName: 'floorNo',
label: '楼层号',
component: 'InputNumber',
componentProps: {
min: -10,
placeholder: '请输入楼层号',
class: 'w-full',
},
},
{
fieldName: 'cleaningFrequency',
label: '保洁频率',
component: 'Input',
componentProps: { placeholder: '如:每日 2 次' },
},
{
fieldName: 'standardDuration',
label: '标准时长(分钟)',
component: 'InputNumber',
componentProps: {
min: 0,
placeholder: '请输入标准时长',
class: 'w-full',
},
},
{
fieldName: 'areaLevel',
label: '区域等级',
component: 'Select',
componentProps: {
placeholder: '请选择区域等级',
allowClear: true,
options: AREA_LEVEL_OPTIONS,
},
},
{
fieldName: 'isActive',
label: '启用状态',
component: 'RadioGroup',
componentProps: {
options: [
{ label: '启用', value: true },
{ label: '停用', value: false },
],
optionType: 'button',
buttonStyle: 'solid',
},
rules: z.boolean().default(true),
},
{
fieldName: 'sort',
label: '排序',
component: 'InputNumber',
componentProps: {
min: 0,
placeholder: '请输入排序',
class: 'w-full',
},
rules: z.number().min(0).default(0),
},
];
}
/** 区域列表列 */
export function useGridColumns(): VxeTableGridOptions<OpsAreaApi.BusArea>['columns'] {
return [
{
field: 'areaName',
title: '区域名称',
minWidth: 160,
align: 'left',
fixed: 'left',
treeNode: true,
},
{ field: 'areaCode', title: '区域编码', minWidth: 120 },
{
field: 'areaType',
title: '区域类型',
minWidth: 100,
formatter: ({ cellValue }) =>
AREA_TYPE_OPTIONS.find((o) => o.value === cellValue)?.label ??
cellValue ??
'-',
},
{
field: 'functionType',
title: '功能类型',
minWidth: 100,
formatter: ({ cellValue }) =>
FUNCTION_TYPE_OPTIONS.find((o) => o.value === cellValue)?.label ??
cellValue ??
'-',
},
{
field: 'floorNo',
title: '楼层号',
minWidth: 88,
formatter: ({ cellValue }) => cellValue ?? '-',
},
{ field: 'cleaningFrequency', title: '保洁频率', minWidth: 110 },
{
field: 'standardDuration',
title: '标准时长',
minWidth: 96,
formatter: ({ cellValue }) =>
cellValue === null ? '-' : `${cellValue} 分钟`,
},
{
field: 'areaLevel',
title: '区域等级',
minWidth: 90,
formatter: ({ cellValue }) =>
AREA_LEVEL_OPTIONS.find((o) => o.value === cellValue)?.label ??
cellValue ??
'-',
},
{
field: 'isActive',
title: '状态',
minWidth: 84,
formatter: ({ cellValue }) => (cellValue ? '启用' : '停用'),
},
{ field: 'sort', title: '排序', minWidth: 72 },
{
title: '操作',
width: 360,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 绑定设备表单 schema */
export function useBindFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'deviceId',
label: '设备',
component: 'ApiSelect',
componentProps: {
api: () => getSimpleDeviceList(),
labelField: 'nickname',
valueField: 'id',
placeholder: '请选择设备',
},
rules: 'selectRequired',
},
{
fieldName: 'relationType',
label: '关联类型',
component: 'Select',
componentProps: {
placeholder: '请选择关联类型',
options: RELATION_TYPE_OPTIONS,
},
rules: 'selectRequired',
},
];
}
/** 已绑定设备列表列 */
export function useDeviceRelationColumns(): VxeTableGridOptions<OpsAreaApi.AreaDeviceRelation>['columns'] {
return [
{
field: 'deviceName',
title: '设备名称',
minWidth: 140,
formatter: ({ row }) => row?.deviceName || row?.deviceKey || '-',
},
{ field: 'deviceKey', title: '设备 Key', minWidth: 140 },
{
field: 'productName',
title: '产品',
minWidth: 120,
formatter: ({ cellValue, row }) => cellValue || row?.productKey || '-',
},
{
field: 'relationType',
title: '关联类型',
minWidth: 100,
formatter: ({ cellValue }) =>
RELATION_TYPE_OPTIONS.find((o) => o.value === cellValue)?.label ??
cellValue ??
'-',
},
{
field: 'enabled',
title: '状态',
minWidth: 84,
formatter: ({ cellValue }) => (cellValue ? '启用' : '停用'),
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'device-actions' },
},
];
}
/** 按 relation_type 获取保洁配置表单 schema */
export function useDeviceConfigFormSchema(
relationType: RelationType,
): VbenFormSchema[] {
switch (relationType) {
case 'BADGE': {
return [
{
fieldName: 'notifyType',
label: '通知方式',
component: 'Select',
componentProps: {
options: [
{ label: '震动', value: 'VIBRATE' },
{ label: '语音', value: 'VOICE' },
],
},
},
{
fieldName: 'voiceTemplate',
label: '语音内容模板',
component: 'Input',
componentProps: { placeholder: '语音播报内容模板' },
},
{
fieldName: 'batteryAlertThreshold',
label: '电量告警阈值(%)',
component: 'InputNumber',
componentProps: {
min: 0,
max: 100,
placeholder: '低于该值告警',
class: 'w-full',
},
},
];
}
case 'BEACON': {
return [
{
fieldName: 'rssiThreshold',
label: 'RSSI 阈值',
component: 'InputNumber',
componentProps: { placeholder: '如 -70', class: 'w-full' },
},
{
fieldName: 'attendanceRule',
label: '到岗判定规则',
component: 'Input',
componentProps: { placeholder: '简要描述到岗判定规则' },
},
];
}
case 'TRAFFIC_COUNTER': {
return [
{
fieldName: 'trafficThreshold',
label: '客流阈值',
component: 'InputNumber',
componentProps: {
min: 1,
placeholder: '超过该人数触发',
class: 'w-full',
},
},
{
fieldName: 'statPeriodMinutes',
label: '统计周期(分钟)',
component: 'InputNumber',
componentProps: { min: 1, placeholder: '统计周期', class: 'w-full' },
},
{
fieldName: 'triggerAction',
label: '触发动作',
component: 'Select',
componentProps: {
placeholder: '请选择',
options: [
{ label: '生成工单', value: 'CREATE_ORDER' },
{ label: '仅告警', value: 'ALERT_ONLY' },
],
},
},
];
}
default: {
return [];
}
}
}

View File

@@ -0,0 +1,296 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { OpsAreaApi } from '#/api/ops/area';
import { ref } from 'vue';
import {
confirm as confirmModal,
Page,
useVbenDrawer,
useVbenModal,
} from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteArea, getAreaTree, updateArea } from '#/api/ops/area';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import DeviceBindDrawer from './modules/device-bind-drawer.vue';
import Form from './modules/form.vue';
defineOptions({ name: 'OpsBusArea' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [DeviceBindDrawerComp, deviceBindDrawerApi] = useVbenDrawer({
connectedComponent: DeviceBindDrawer,
destroyOnClose: true,
});
/** 树形展开/收缩 */
const isExpanded = ref(false);
function handleExpand() {
isExpanded.value = !isExpanded.value;
gridApi.grid?.setAllTreeExpand(isExpanded.value);
}
function handleRefresh() {
gridApi.query();
}
function handleCreate() {
formModalApi.setData({ parentId: 0 }).open();
}
function handleAppend(row: OpsAreaApi.BusArea) {
formModalApi.setData({ parentId: row.id }).open();
}
function handleEdit(row: OpsAreaApi.BusArea) {
formModalApi.setData(row).open();
}
async function handleDelete(row: OpsAreaApi.BusArea) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.areaName]),
duration: 0,
});
try {
await deleteArea(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.areaName]));
handleRefresh();
} catch (error: any) {
const msg = error?.message || error?.data?.msg || '删除失败';
message.error(msg);
} finally {
hideLoading();
}
}
async function handleToggleActive(row: OpsAreaApi.BusArea) {
const next = !row.isActive;
if (!next) {
try {
await confirmModal('确认停用该区域吗?停用后相关工单策略可能受影响。');
} catch {
return;
}
}
const hideLoading = message.loading({
content: next ? '启用中...' : '停用中...',
duration: 0,
});
try {
await updateArea({ ...row, isActive: next });
message.success(next ? '已启用' : '已停用');
handleRefresh();
} catch (error: any) {
const msg = error?.message || error?.data?.msg || '操作失败';
message.error(msg);
} finally {
hideLoading();
}
}
function handleBindDevice(row: OpsAreaApi.BusArea) {
deviceBindDrawerApi.setData(row).open();
}
const hasChildren = (row: OpsAreaApi.BusArea) => {
const c = (row as any).children;
return Array.isArray(c) && c.length > 0;
};
/**
* 🔥 懒加载:动态加载子节点
* 当用户点击展开图标时,此方法会被调用
*/
async function loadChildren({ row }: any) {
try {
// 从全量树中过滤子节点(临时方案)
// TODO: 后端实现 GET /api/ops/area/{parentId}/children 后替换为真实API
const allData = await getAreaTree();
const allList = Array.isArray(allData)
? allData
: ((allData as any)?.list ?? []);
// 查找当前节点的直接子节点
const children = allList.filter((item: any) => item.parentId === row.id);
// 为每个子节点添加 hasChildren 标识
return children.map((child: any) => {
const grandChildCount = allList.filter(
(item: any) => item.parentId === child.id,
).length;
return {
...child,
hasChildren: grandChildCount > 0,
};
});
} catch (error: any) {
message.error(`加载子节点失败: ${error?.message || '未知错误'}`);
return [];
}
}
/**
* 🔥 懒加载:只加载根节点
*/
async function loadRootNodes(formValues: Record<string, any>) {
const params: OpsAreaApi.AreaTreeQuery = {};
if (formValues?.name) params.name = formValues.name;
if (formValues?.areaType) params.areaType = formValues.areaType;
if (formValues?.isActive !== undefined && formValues?.isActive !== '')
params.isActive = formValues.isActive;
// 从全量树中提取根节点(临时方案)
// TODO: 后端实现 GET /api/ops/area/root 后替换为真实API
const data = await getAreaTree(params);
const allList = Array.isArray(data) ? data : ((data as any)?.list ?? []);
// 提取根节点parentId为null、undefined或0
const rootNodes = allList.filter(
(item: any) => !item.parentId || item.parentId === 0,
);
// 为每个根节点添加 hasChildren 标识
return rootNodes.map((node: any) => {
const childCount = allList.filter(
(item: any) => item.parentId === node.id,
).length;
return {
...node,
hasChildren: childCount > 0,
};
});
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
maxHeight: 700,
pagerConfig: { enabled: false },
proxyConfig: {
ajax: {
query: async (_: any, formValues: Record<string, any>) => {
// 🔥 懒加载模式:仅加载根节点
const list = await loadRootNodes(formValues);
return { list, total: list.length };
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
scrollY: {
enabled: true,
gt: 50,
},
toolbarConfig: {
refresh: true,
search: true,
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
lazy: true, // ✅ 启用懒加载
hasChild: 'hasChildren', // ✅ 标识是否有子节点的字段
loadMethod: loadChildren, // ✅ 加载子节点的方法
reserve: true,
accordion: false,
},
} as VxeTableGridOptions<OpsAreaApi.BusArea>,
});
</script>
<template>
<Page auto-content-height class="ops-area-page">
<FormModal @success="handleRefresh" />
<DeviceBindDrawerComp @refresh="handleRefresh" />
<!-- 主要内容区域 - 树形表格 -->
<div
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
<Grid table-title="业务区域管理">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: '新增区域',
type: 'primary',
icon: ACTION_ICON.ADD,
// auth: ['ops:area:create'],
onClick: handleCreate,
},
{
label: isExpanded ? '收缩全部' : '展开全部',
type: 'default',
icon: isExpanded ? 'lucide:minimize-2' : 'lucide:maximize-2',
onClick: handleExpand,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '新增下级',
type: 'link',
icon: ACTION_ICON.ADD,
// auth: ['ops:area:create'],
onClick: () => handleAppend(row),
},
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
// auth: ['ops:area:update'],
onClick: () => handleEdit(row),
},
{
label: row.isActive ? '停用' : '启用',
type: 'link',
icon: 'lucide:power',
// auth: ['ops:area:update'],
onClick: () => handleToggleActive(row),
},
{
label: '绑定设备',
type: 'link',
icon: 'lucide:link',
// auth: ['ops:area:bind-device'],
onClick: () => handleBindDevice(row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
// auth: ['ops:area:delete'],
disabled: hasChildren(row),
popConfirm: {
title: `确认删除区域【${row.areaName}】吗?删除后其下级区域将无法归属,请先处理子区域或关联设备。`,
confirm: () => handleDelete(row),
},
},
]"
/>
</template>
</Grid>
</div>
</Page>
</template>
<style scoped></style>

View File

@@ -0,0 +1,223 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { OpsAreaApi } from '#/api/ops/area';
import { ref } from 'vue';
import {
confirm as confirmModal,
Page,
useVbenDrawer,
useVbenModal,
} from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteArea, getAreaTree, updateArea } from '#/api/ops/area';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import DeviceBindDrawer from './modules/device-bind-drawer.vue';
import Form from './modules/form.vue';
defineOptions({ name: 'OpsBusArea' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
const [DeviceBindDrawerComp, deviceBindDrawerApi] = useVbenDrawer({
connectedComponent: DeviceBindDrawer,
destroyOnClose: true,
});
/** 树形展开/收缩 */
const isExpanded = ref(true);
function handleExpand() {
isExpanded.value = !isExpanded.value;
gridApi.grid?.setAllTreeExpand(isExpanded.value);
}
function handleRefresh() {
gridApi.query();
}
function handleCreate() {
formModalApi.setData({ parentId: 0 }).open();
}
function handleAppend(row: OpsAreaApi.BusArea) {
formModalApi.setData({ parentId: row.id }).open();
}
function handleEdit(row: OpsAreaApi.BusArea) {
formModalApi.setData(row).open();
}
async function handleDelete(row: OpsAreaApi.BusArea) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.areaName]),
duration: 0,
});
try {
await deleteArea(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.areaName]));
handleRefresh();
} catch (error: any) {
const msg = error?.message || error?.data?.msg || '删除失败';
message.error(msg);
} finally {
hideLoading();
}
}
async function handleToggleActive(row: OpsAreaApi.BusArea) {
const next = !row.isActive;
if (!next) {
try {
await confirmModal('确认停用该区域吗?停用后相关工单策略可能受影响。');
} catch {
return;
}
}
const hideLoading = message.loading({
content: next ? '启用中...' : '停用中...',
duration: 0,
});
try {
await updateArea({ ...row, isActive: next });
message.success(next ? '已启用' : '已停用');
handleRefresh();
} catch (error: any) {
const msg = error?.message || error?.data?.msg || '操作失败';
message.error(msg);
} finally {
hideLoading();
}
}
function handleBindDevice(row: OpsAreaApi.BusArea) {
deviceBindDrawerApi.setData(row).open();
}
const hasChildren = (row: OpsAreaApi.BusArea) => {
const c = (row as any).children;
return Array.isArray(c) && c.length > 0;
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
pagerConfig: { enabled: false },
proxyConfig: {
ajax: {
query: async (_: any, formValues: Record<string, any>) => {
const params: OpsAreaApi.AreaTreeQuery = {};
if (formValues?.name) params.name = formValues.name;
if (formValues?.areaType) params.areaType = formValues.areaType;
if (formValues?.isActive !== undefined && formValues?.isActive !== '')
params.isActive = formValues.isActive;
const data = await getAreaTree(params);
const list = Array.isArray(data) ? data : ((data as any)?.list ?? []);
return { list, total: list.length };
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
expandAll: true,
reserve: true,
},
} as VxeTableGridOptions<OpsAreaApi.BusArea>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<DeviceBindDrawerComp @refresh="handleRefresh" />
<Grid table-title="业务区域管理">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: '新增',
type: 'primary',
icon: ACTION_ICON.ADD,
// auth: ['ops:area:create'],
onClick: handleCreate,
},
{
label: isExpanded ? '收缩' : '展开',
type: 'primary',
onClick: handleExpand,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '新增下级',
type: 'link',
icon: ACTION_ICON.ADD,
// auth: ['ops:area:create'],
onClick: () => handleAppend(row),
},
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
// auth: ['ops:area:update'],
onClick: () => handleEdit(row),
},
{
label: row.isActive ? '停用' : '启用',
type: 'link',
icon: 'lucide:power',
// auth: ['ops:area:update'],
onClick: () => handleToggleActive(row),
},
{
label: '绑定设备',
type: 'link',
icon: 'lucide:link',
// auth: ['ops:area:bind-device'],
onClick: () => handleBindDevice(row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
// auth: ['ops:area:delete'],
disabled: hasChildren(row),
popConfirm: {
title: `确认删除区域【${row.areaName}】吗?删除后其下级区域将无法归属,请先处理子区域或关联设备。`,
confirm: () => handleDelete(row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,549 @@
<script lang="ts" setup>
import type { TableColumnsType } from 'ant-design-vue';
import type { OpsAreaApi } from '#/api/ops/area';
import { computed, ref } from 'vue';
import {
confirm as confirmModal,
useVbenDrawer,
useVbenModal,
} from '@vben/common-ui';
import { Badge, Button, Empty, message, Table, Tag } from 'ant-design-vue';
import { getDeviceRelationList, removeDeviceRelation } from '#/api/ops/area';
import { RELATION_TYPE_OPTIONS } from '../data';
import DeviceConfigModal from './device-config-modal.vue';
import DeviceSelectModal from './device-select-modal.vue';
const emit = defineEmits<{ (e: 'refresh'): void }>();
const area = ref<null | OpsAreaApi.BusArea>(null);
const list = ref<OpsAreaApi.AreaDeviceRelation[]>([]);
const loading = ref(false);
const [Drawer, drawerApi] = useVbenDrawer({
onOpenChange(isOpen: boolean) {
if (!isOpen) {
area.value = null;
list.value = [];
return;
}
const a = drawerApi.getData<OpsAreaApi.BusArea>();
area.value = a ?? null;
if (a?.id) fetchList(a.id);
},
});
const [ConfigModal, configModalApi] = useVbenModal({
connectedComponent: DeviceConfigModal,
destroyOnClose: true,
});
const [DeviceSelectModalComp, deviceSelectModalApi] = useVbenModal({
connectedComponent: DeviceSelectModal,
destroyOnClose: true,
});
function openBindForm() {
const a = area.value;
if (!a?.id) return;
deviceSelectModalApi.setData({ areaId: a.id }).open();
}
function openConfig(row: OpsAreaApi.AreaDeviceRelation) {
configModalApi.setData(row).open();
}
function onBindSuccess() {
const a = area.value;
if (a?.id) fetchList(a.id);
emit('refresh');
}
function onConfigSuccess() {
const a = area.value;
if (a?.id) fetchList(a.id);
emit('refresh');
}
async function handleUnbind(row: OpsAreaApi.AreaDeviceRelation) {
const name = row.nickname || row.deviceKey || '该设备';
try {
await confirmModal(`确认解除设备【${name}】与该区域的绑定吗?`);
} catch {
return;
}
const hide = message.loading({ content: '解除中...', duration: 0 });
try {
await removeDeviceRelation(row.id!);
message.success('已解除绑定');
const a = area.value;
if (a?.id) await fetchList(a.id);
emit('refresh');
} catch (error: any) {
message.error(error?.message || error?.data?.msg || '操作失败');
} finally {
hide();
}
}
const modalTitle = computed(() => {
const a = area.value;
return a ? `${a.areaName} - 设备绑定管理` : '设备绑定管理';
});
const deviceCount = computed(() => list.value.length);
const activeDeviceCount = computed(
() => list.value.filter((d) => d.enabled).length,
);
async function fetchList(areaId: number) {
loading.value = true;
try {
const res = await getDeviceRelationList(areaId);
list.value = Array.isArray(res) ? res : [];
} catch {
list.value = [];
} finally {
loading.value = false;
}
}
const columns: TableColumnsType<OpsAreaApi.AreaDeviceRelation> = [
{
title: '设备昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 140,
ellipsis: true,
customRender: ({ record }) => record?.nickname || '-',
},
{
title: '设备 Key',
dataIndex: 'deviceKey',
key: 'deviceKey',
width: 140,
ellipsis: true,
},
{
title: '产品',
key: 'product',
width: 120,
customRender: ({ record }) =>
record?.productName || record?.productKey || '-',
},
{
title: '关联类型',
dataIndex: 'relationType',
key: 'relationType',
width: 120,
customRender: ({ text }) => {
const option = RELATION_TYPE_OPTIONS.find((o) => o.value === text);
return option ? option.label : text || '-';
},
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 100,
},
{
title: '操作',
key: 'actions',
width: 180,
fixed: 'right',
},
];
// 根据关联类型获取颜色
function getRelationTypeColor(type: string) {
const colorMap: Record<string, string> = {
TRAFFIC_COUNTER: 'blue',
BEACON: 'purple',
BADGE: 'orange',
};
return colorMap[type] || 'default';
}
</script>
<template>
<Drawer :title="modalTitle" class="device-bind-drawer" width="900px">
<!-- 抽屉头部统计信息 -->
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<!-- 总设备数 -->
<div
class="rounded-lg border border-gray-200 bg-gradient-to-br from-blue-50 to-white p-4 dark:border-gray-700 dark:from-blue-950/30 dark:to-gray-900"
>
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">
绑定设备
</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{{ deviceCount }}
</p>
</div>
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/50">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
</div>
</div>
<!-- 启用设备 -->
<div
class="rounded-lg border border-gray-200 bg-gradient-to-br from-green-50 to-white p-4 dark:border-gray-700 dark:from-green-950/30 dark:to-gray-900"
>
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">
启用设备
</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{{ activeDeviceCount }}
</p>
</div>
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/50">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-green-600 dark:text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
</div>
<!-- 停用设备 -->
<div
class="rounded-lg border border-gray-200 bg-gradient-to-br from-orange-50 to-white p-4 dark:border-gray-700 dark:from-orange-950/30 dark:to-gray-900"
>
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-600 dark:text-gray-400">
停用设备
</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{{ deviceCount - activeDeviceCount }}
</p>
</div>
<div class="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/50">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-orange-600 dark:text-orange-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
</div>
</div>
<!-- 操作栏 -->
<div
class="mb-4 flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-gray-600 dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm text-gray-700 dark:text-gray-300">管理区域内所有绑定的IoT设备及其配置</span>
</div>
<Button
type="primary"
:disabled="!area?.id"
@click="openBindForm"
class="shadow-sm transition-shadow hover:shadow-md"
>
<template #icon>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</template>
绑定设备
</Button>
</div>
<!-- 设备列表 -->
<div
class="rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
<Table
:columns="columns"
:data-source="list"
:loading="loading"
:pagination="{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
}"
row-key="id"
size="middle"
:scroll="{ x: 800 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'nickname'">
<div class="flex items-center gap-2">
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<span class="font-medium">{{ record.nickname || '-' }}</span>
</div>
</template>
<template v-if="column.key === 'relationType'">
<Tag :color="getRelationTypeColor(record.relationType)">
{{
RELATION_TYPE_OPTIONS.find(
(o) => o.value === record.relationType,
)?.label ?? record.relationType
}}
</Tag>
</template>
<template v-if="column.key === 'enabled'">
<Badge
:status="record.enabled ? 'success' : 'error'"
:text="record.enabled ? '启用' : '停用'"
/>
</template>
<template v-if="column.key === 'actions'">
<div class="flex gap-2">
<Button
type="link"
size="small"
@click="openConfig(record)"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
<template #icon>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</template>
配置
</Button>
<Button
type="link"
size="small"
danger
@click="handleUnbind(record)"
class="hover:bg-red-50 dark:hover:bg-red-950/30"
>
<template #icon>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
</template>
解绑
</Button>
</div>
</template>
</template>
<template #emptyText>
<Empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="该区域暂无绑定设备"
class="py-8"
>
<Button type="primary" @click="openBindForm" class="mt-4">
<template #icon>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</template>
立即绑定设备
</Button>
</Empty>
</template>
</Table>
</div>
<!-- 底部提示 -->
<div
class="mt-4 rounded-lg border-l-4 border-amber-500 bg-amber-50 p-4 dark:bg-amber-950/30"
>
<div class="flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="text-sm font-medium text-amber-800 dark:text-amber-300">
设备绑定说明
</p>
<ul class="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-400">
<li> 客流计数设备用于监测区域人流量,触发保洁任务</li>
<li> 信标设备用于人员定位和到岗判定</li>
<li> 工牌设备用于保洁人员任务通知</li>
</ul>
</div>
</div>
</div>
</Drawer>
<DeviceSelectModalComp @success="onBindSuccess" />
<ConfigModal @success="onConfigSuccess" />
</template>
<style scoped>
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.device-bind-drawer :deep(.ant-drawer-header) {
border-bottom-color: rgb(55 65 81 / 100%);
}
.device-bind-drawer :deep(.ant-table-thead > tr > th) {
background: rgb(31 41 55 / 100%);
}
}
/* 响应式 */
@media (max-width: 768px) {
.device-bind-drawer {
width: 100% !important;
}
.grid-cols-3 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
.device-bind-drawer :deep(.ant-drawer-header) {
padding: 20px 24px;
border-bottom: 1px solid rgb(229 231 235 / 100%);
}
.device-bind-drawer :deep(.ant-drawer-body) {
padding: 24px;
}
.device-bind-drawer :deep(.ant-table) {
font-size: 14px;
}
.device-bind-drawer :deep(.ant-table-thead > tr > th) {
font-weight: 600;
background: rgb(249 250 251 / 100%);
}
</style>

View File

@@ -0,0 +1,219 @@
<script lang="ts" setup>
import type { TableColumnsType } from 'ant-design-vue';
import type { OpsAreaApi } from '#/api/ops/area';
import { computed, ref } from 'vue';
import {
confirm as confirmModal,
useVbenDrawer,
useVbenModal,
} from '@vben/common-ui';
import { Badge, Button, Empty, message, Table, Tag } from 'ant-design-vue';
import { getDeviceRelationList, removeDeviceRelation } from '#/api/ops/area';
import { RELATION_TYPE_OPTIONS } from '../data';
import DeviceConfigModal from './device-config-modal.vue';
import DeviceSelectModal from './device-select-modal.vue';
const emit = defineEmits<{ (e: 'refresh'): void }>();
const area = ref<null | OpsAreaApi.BusArea>(null);
const list = ref<OpsAreaApi.AreaDeviceRelation[]>([]);
const loading = ref(false);
const [Drawer, drawerApi] = useVbenDrawer({
onOpenChange(isOpen: boolean) {
if (!isOpen) {
area.value = null;
list.value = [];
return;
}
const a = drawerApi.getData<OpsAreaApi.BusArea>();
area.value = a ?? null;
if (a?.id) fetchList(a.id);
},
});
const [ConfigModal, configModalApi] = useVbenModal({
connectedComponent: DeviceConfigModal,
destroyOnClose: true,
});
const [DeviceSelectModalComp, deviceSelectModalApi] = useVbenModal({
connectedComponent: DeviceSelectModal,
destroyOnClose: true,
});
function openBindForm() {
const a = area.value;
if (!a?.id) return;
deviceSelectModalApi.setData({ areaId: a.id }).open();
}
function openConfig(row: OpsAreaApi.AreaDeviceRelation) {
configModalApi.setData(row).open();
}
function onBindSuccess() {
const a = area.value;
if (a?.id) fetchList(a.id);
emit('refresh');
}
function onConfigSuccess() {
const a = area.value;
if (a?.id) fetchList(a.id);
emit('refresh');
}
async function handleUnbind(row: OpsAreaApi.AreaDeviceRelation) {
const name = row.deviceName || row.deviceKey || '该设备';
try {
await confirmModal(`确认解除设备【${name}】与该区域的绑定吗?`);
} catch {
return;
}
const hide = message.loading({ content: '解除中...', duration: 0 });
try {
await removeDeviceRelation(row.id!);
message.success('已解除绑定');
const a = area.value;
if (a?.id) await fetchList(a.id);
emit('refresh');
} catch (error: any) {
message.error(error?.message || error?.data?.msg || '操作失败');
} finally {
hide();
}
}
const modalTitle = computed(() => {
const a = area.value;
return a ? `${a.areaName} - 设备绑定` : '设备绑定';
});
async function fetchList(areaId: number) {
loading.value = true;
try {
const res = await getDeviceRelationList(areaId);
list.value = Array.isArray(res) ? res : [];
} catch {
list.value = [];
} finally {
loading.value = false;
}
}
const columns: TableColumnsType<OpsAreaApi.AreaDeviceRelation> = [
{
title: '设备昵称',
dataIndex: 'deviceName',
key: 'deviceName',
width: 140,
ellipsis: true,
customRender: ({ record }) => record?.deviceName || '-',
},
{
title: '设备 Key',
dataIndex: 'deviceKey',
key: 'deviceKey',
width: 140,
ellipsis: true,
},
{
title: '产品',
key: 'product',
width: 120,
customRender: ({ record }) =>
record?.productName || record?.productKey || '-',
},
{
title: '关联类型',
dataIndex: 'relationType',
key: 'relationType',
width: 120,
customRender: ({ text }) => {
const option = RELATION_TYPE_OPTIONS.find((o) => o.value === text);
return option ? option.label : text || '-';
},
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 100,
},
{
title: '操作',
key: 'actions',
width: 160,
fixed: 'right',
},
];
</script>
<template>
<Drawer :title="modalTitle" class="w-[800px]">
<div class="space-y-4">
<div class="flex justify-end">
<Button type="primary" :disabled="!area?.id" @click="openBindForm">
绑定设备
</Button>
</div>
<Table
:columns="columns"
:data-source="list"
:loading="loading"
:pagination="false"
row-key="id"
size="small"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'relationType'">
<Tag color="blue">
{{
RELATION_TYPE_OPTIONS.find(
(o) => o.value === record.relationType,
)?.label ?? record.relationType
}}
</Tag>
</template>
<template v-if="column.key === 'enabled'">
<Badge
:status="record.enabled ? 'success' : 'error'"
:text="record.enabled ? '启用' : '停用'"
/>
</template>
<template v-if="column.key === 'actions'">
<div class="flex gap-2">
<Button type="link" size="small" @click="openConfig(record)">
编辑配置
</Button>
<Button
type="link"
size="small"
danger
@click="handleUnbind(record)"
>
解除绑定
</Button>
</div>
</template>
</template>
<template #emptyText>
<Empty description="该区域暂无绑定设备,点击上方按钮绑定">
<Button type="primary" @click="openBindForm">去绑定</Button>
</Empty>
</template>
</Table>
</div>
</Drawer>
<DeviceSelectModalComp @success="onBindSuccess" />
<ConfigModal @success="onConfigSuccess" />
</template>

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { OpsAreaApi } from '#/api/ops/area';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CodeEditor } from '@vben/plugins/code-editor';
import { message } from 'ant-design-vue';
import { updateDeviceRelation } from '#/api/ops/area';
import { RELATION_TYPE_OPTIONS } from '../data';
const emit = defineEmits<{ (e: 'success'): void }>();
const relation = ref<null | OpsAreaApi.AreaDeviceRelation>(null);
const jsonValue = ref('');
const title = computed(() => {
const r = relation.value;
if (!r) return '设备配置';
const typeLabel =
RELATION_TYPE_OPTIONS.find((o) => o.value === r.relationType)?.label ??
r.relationType;
const name = r.nickname || r.deviceName || r.deviceKey || '设备';
return `${name} - ${typeLabel}配置`;
});
function handleChange(val: string) {
jsonValue.value = val;
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
let configData = {};
try {
if (jsonValue.value) {
configData = JSON.parse(jsonValue.value);
}
} catch {
message.error('JSON 格式错误,请检查');
return;
}
const r = relation.value;
if (!r?.id) return;
modalApi.lock();
try {
await updateDeviceRelation({
id: r.id,
configData,
enabled: r.enabled,
});
await modalApi.close();
emit('success');
message.success('配置已保存');
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
relation.value = null;
jsonValue.value = '';
return;
}
const r = modalApi.getData<OpsAreaApi.AreaDeviceRelation>();
relation.value = r ?? null;
jsonValue.value =
r?.configData && Object.keys(r.configData).length > 0
? JSON.stringify(r.configData, null, 2)
: '{\n \n}';
},
});
</script>
<template>
<Modal :title="title">
<div class="h-[400px] w-full px-4">
<CodeEditor
:value="jsonValue"
:bordered="true"
:auto-format="false"
@change="handleChange"
/>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,240 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device';
import { nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Form, message, Select, Step, Steps } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDevicePage } from '#/api/iot/device/device';
import { bindDevice } from '#/api/ops/area';
import { RELATION_TYPE_OPTIONS } from '../data';
const emit = defineEmits<{ (e: 'success'): void }>();
const areaId = ref<number>(0);
const selectedDevice = ref<IotDeviceApi.Device | null>(null);
const relationType = ref<string | undefined>(undefined);
const currentStep = ref(0);
const confirmLoading = ref(false);
const [Modal, modalApi] = useVbenModal({
onOpenChange: async (isOpen: boolean) => {
if (isOpen) {
const data = modalApi.getData<{ areaId: number }>();
areaId.value = data?.areaId ?? 0;
currentStep.value = 0;
selectedDevice.value = null;
relationType.value = undefined;
await nextTick();
gridApi.reload();
updateFooterState();
}
},
onConfirm: async () => {
if (currentStep.value === 1) {
await handleConfirmBind();
}
},
onCancel: () => {
if (currentStep.value === 1) {
currentStep.value = 0;
updateFooterState();
// Prevent modal close
// return false; // useVbenModal hooks don't support returning false to stop close in onCancel,
// but we can manage it by not binding the default close behavior or re-opening.
// However, usually onCancel simply closes.
// If we want "Back" behavior, we should use a custom footer or a specific "Back" button.
}
},
});
function updateFooterState() {
if (currentStep.value === 0) {
modalApi.setState({
showConfirmButton: false,
showCancelButton: true,
cancelText: '取消',
});
} else {
modalApi.setState({
showConfirmButton: true,
showCancelButton: false, // Hide default cancel, use custom "Back" in template if needed
confirmText: '确认绑定',
confirmLoading: confirmLoading.value,
});
}
}
const gridOptions: VxeTableGridOptions<IotDeviceApi.Device> = {
columns: [
{ field: 'deviceName', title: '设备名称', minWidth: 120 },
{ field: 'nickname', title: '设备昵称', minWidth: 120 },
{
field: 'productName',
title: '产品名称',
minWidth: 120,
formatter: ({ cellValue }) => cellValue || '-',
},
{
title: '操作',
width: 100,
fixed: 'right',
slots: { default: 'action' },
},
],
pagerConfig: {
enabled: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDevicePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
toolbarConfig: {
search: true,
refresh: true,
custom: false,
zoom: false,
},
};
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions,
formOptions: {
schema: [
{
fieldName: 'deviceName',
label: '设备名称',
component: 'Input',
componentProps: {
allowClear: true,
},
},
{
fieldName: 'nickname',
label: '设备昵称',
component: 'Input',
componentProps: {
allowClear: true,
},
},
],
},
});
function handleSelect(row: IotDeviceApi.Device) {
selectedDevice.value = row;
relationType.value = undefined;
currentStep.value = 1;
updateFooterState();
}
function handleBack() {
currentStep.value = 0;
updateFooterState();
}
async function handleConfirmBind() {
if (!relationType.value) {
message.warning('请选择关联类型');
return;
}
if (!selectedDevice.value || !areaId.value) return;
confirmLoading.value = true;
modalApi.setState({ confirmLoading: true });
try {
await bindDevice({
areaId: areaId.value,
deviceId: selectedDevice.value.id!,
relationType: relationType.value,
enabled: true,
});
message.success('绑定成功');
modalApi.close();
emit('success');
} catch {
// message.error(e.message || '绑定失败');
} finally {
confirmLoading.value = false;
modalApi.setState({ confirmLoading: false });
}
}
</script>
<template>
<Modal title="绑定设备" class="w-[900px]">
<div class="px-8 pt-4">
<Steps :current="currentStep" size="small">
<Step title="选择设备" />
<Step title="配置关联" />
</Steps>
</div>
<div v-show="currentStep === 0" class="mt-4 h-full">
<Grid>
<template #action="{ row }">
<Button type="link" size="small" @click="handleSelect(row)">
选择
</Button>
</template>
</Grid>
</div>
<div v-show="currentStep === 1" class="mt-8 h-full px-12">
<Form layout="vertical">
<Form.Item label="当前设备">
<div class="rounded border border-gray-200 bg-gray-50 p-3">
<span class="mr-2 text-lg font-bold">{{
selectedDevice?.nickname || selectedDevice?.deviceName
}}</span>
<span
v-if="selectedDevice?.productName"
class="text-sm text-gray-500"
>
({{ selectedDevice?.productName }})
</span>
</div>
</Form.Item>
<Form.Item
label="关联类型"
required
help="请选择该设备在当前区域中的业务用途"
>
<Select
v-model:value="relationType"
placeholder="请选择关联类型"
:options="RELATION_TYPE_OPTIONS"
class="w-full"
size="large"
/>
</Form.Item>
</Form>
</div>
<!-- Custom Footer for Step 1 -->
<template #footer>
<div v-if="currentStep === 1" class="flex justify-end gap-2">
<Button @click="handleBack">上一步</Button>
<Button
type="primary"
:loading="confirmLoading"
@click="handleConfirmBind"
>
确认绑定
</Button>
</div>
</template>
</Modal>
</template>

View File

@@ -0,0 +1,286 @@
<script lang="ts" setup>
import type { OpsAreaApi } from '#/api/ops/area';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createArea, getArea, updateArea } from '#/api/ops/area';
import { $t } from '#/locales';
import { useAreaFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<null | OpsAreaApi.BusArea | { parentId?: number }>();
const isAddChild = computed(() => {
const d = formData.value;
return (
!!d &&
!('id' in d) &&
'parentId' in d &&
(d as any).parentId !== null &&
(d as any).parentId !== 0
);
});
const getTitle = computed(() => {
if (formData.value && 'id' in formData.value && formData.value.id) {
return '编辑区域';
}
return isAddChild.value ? '新增下级区域' : '新增区域';
});
const getIcon = computed(() => {
if (formData.value && 'id' in formData.value && formData.value.id) {
return 'lucide:edit';
}
return isAddChild.value ? 'lucide:folder-plus' : 'lucide:plus-circle';
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useAreaFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const valid = await formApi.validate();
if (!valid) return;
modalApi.lock();
const data = (await formApi.getValues()) as OpsAreaApi.BusArea;
try {
await (formData.value && 'id' in formData.value && formData.value.id
? updateArea(data)
: createArea({
...data,
parentId: data.parentId === 0 ? undefined : data.parentId,
}));
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
const data = modalApi.getData<OpsAreaApi.BusArea | { parentId?: number }>();
formData.value = data ?? null;
if (!data) return;
if ('id' in data && data.id) {
modalApi.lock();
try {
const detail = await getArea(data.id);
await formApi.setValues(detail);
} finally {
modalApi.unlock();
}
} else {
await formApi.setValues({
parentId: (data as any).parentId ?? 0,
isActive: true,
sort: 0,
});
}
},
});
</script>
<template>
<Modal :title="getTitle" class="area-form-modal" width="720px">
<!-- 模态框头部图标装饰 -->
<template #title>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 shadow-md"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
v-if="getIcon === 'lucide:edit'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
<path
v-else-if="getIcon === 'lucide:folder-plus'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ getTitle }}
</h3>
<p v-if="isAddChild" class="text-xs text-gray-500 dark:text-gray-400">
为当前区域添加下级子区域
</p>
<p
v-else-if="formData && 'id' in formData && formData.id"
class="text-xs text-gray-500 dark:text-gray-400"
>
修改区域信息
</p>
<p v-else class="text-xs text-gray-500 dark:text-gray-400">
创建新的业务区域
</p>
</div>
</div>
</template>
<!-- 表单内容 -->
<div class="area-form-content">
<!-- 提示信息 -->
<div
v-if="isAddChild"
class="mb-4 rounded-lg border-l-4 border-blue-500 bg-blue-50 p-4 dark:bg-blue-950/30"
>
<div class="flex items-start">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="ml-3">
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
新增下级区域
</p>
<p class="mt-1 text-xs text-blue-700 dark:text-blue-400">
新区域将作为当前区域的子级,继承上级区域的部分属性
</p>
</div>
</div>
</div>
<!-- 表单区域 -->
<div
class="rounded-lg border border-gray-200 bg-gray-50/50 p-6 dark:border-gray-700 dark:bg-gray-800/50"
>
<Form class="area-form" />
</div>
<!-- 底部提示 -->
<div
class="mt-4 flex items-start gap-2 rounded-lg bg-amber-50 p-3 dark:bg-amber-950/30"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p class="text-xs text-amber-800 dark:text-amber-300">
<strong>注意:</strong>
区域编码建议使用层级结构(:A-B-C),停用区域将影响相关工单策略
</p>
</div>
</div>
</Modal>
</template>
<style scoped>
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.area-form-modal :deep(.ant-modal-header) {
border-bottom-color: rgb(55 65 81 / 100%);
}
}
/* 暗色模式滚动条 */
@media (prefers-color-scheme: dark) {
.area-form-modal :deep(.ant-modal-body)::-webkit-scrollbar-thumb {
background: rgb(75 85 99 / 100%);
}
.area-form-modal :deep(.ant-modal-body)::-webkit-scrollbar-thumb:hover {
background: rgb(107 114 128 / 100%);
}
}
.area-form-modal :deep(.ant-modal-header) {
padding: 20px 24px;
border-bottom: 1px solid rgb(229 231 235 / 100%);
}
.area-form-modal :deep(.ant-modal-body) {
max-height: 600px;
padding: 24px;
overflow-y: auto;
}
.area-form-content {
/* 表单内容样式 */
}
.area-form :deep(.ant-form-item) {
margin-bottom: 20px;
}
.area-form :deep(.ant-form-item-label) {
font-weight: 500;
}
/* 滚动条美化 */
.area-form-modal :deep(.ant-modal-body)::-webkit-scrollbar {
width: 6px;
}
.area-form-modal :deep(.ant-modal-body)::-webkit-scrollbar-track {
background: transparent;
}
.area-form-modal :deep(.ant-modal-body)::-webkit-scrollbar-thumb {
background: rgb(209 213 219 / 100%);
border-radius: 3px;
}
.area-form-modal :deep(.ant-modal-body)::-webkit-scrollbar-thumb:hover {
background: rgb(156 163 175 / 100%);
}
</style>

View File

@@ -0,0 +1,99 @@
<script lang="ts" setup>
import type { OpsAreaApi } from '#/api/ops/area';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createArea, getArea, updateArea } from '#/api/ops/area';
import { $t } from '#/locales';
import { useAreaFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<null | OpsAreaApi.BusArea | { parentId?: number }>();
const isAddChild = computed(() => {
const d = formData.value;
return (
!!d &&
!('id' in d) &&
'parentId' in d &&
(d as any).parentId !== null &&
(d as any).parentId !== 0
);
});
const getTitle = computed(() => {
if (formData.value && 'id' in formData.value && formData.value.id) {
return '编辑区域';
}
return isAddChild.value ? '新增下级区域' : '新增区域';
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: { class: 'w-full' },
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useAreaFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const valid = await formApi.validate();
if (!valid) return;
modalApi.lock();
const data = (await formApi.getValues()) as OpsAreaApi.BusArea;
try {
await (formData.value && 'id' in formData.value && formData.value.id
? updateArea(data)
: createArea({
...data,
parentId: data.parentId === 0 ? undefined : data.parentId,
}));
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
const data = modalApi.getData<OpsAreaApi.BusArea | { parentId?: number }>();
formData.value = data ?? null;
if (!data) return;
if ('id' in data && data.id) {
modalApi.lock();
try {
const detail = await getArea(data.id);
await formApi.setValues(detail);
} finally {
modalApi.unlock();
}
} else {
await formApi.setValues({
parentId: (data as any).parentId ?? 0,
isActive: true,
sort: 0,
});
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>