feat(@vben/web-antd): 新增区域管理模块视图
- 新建区域管理页面目录结构 - 支持区域层级管理(建筑/楼层/功能区) - 设备绑定抽屉:支持区域内IoT设备管理 - 区域表单:基础版本和增强版本 - 设备配置模态框和选择模态框 - 修复ESLint警告:使用严格相等运算符 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
632
apps/web-antd/src/views/ops/area/data-enhanced.ts
Normal file
632
apps/web-antd/src/views/ops/area/data-enhanced.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
458
apps/web-antd/src/views/ops/area/data.ts
Normal file
458
apps/web-antd/src/views/ops/area/data.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
296
apps/web-antd/src/views/ops/area/index-enhanced.vue
Normal file
296
apps/web-antd/src/views/ops/area/index-enhanced.vue
Normal 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>
|
||||
223
apps/web-antd/src/views/ops/area/index.vue
Normal file
223
apps/web-antd/src/views/ops/area/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
219
apps/web-antd/src/views/ops/area/modules/device-bind-drawer.vue
Normal file
219
apps/web-antd/src/views/ops/area/modules/device-bind-drawer.vue
Normal 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>
|
||||
@@ -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>
|
||||
240
apps/web-antd/src/views/ops/area/modules/device-select-modal.vue
Normal file
240
apps/web-antd/src/views/ops/area/modules/device-select-modal.vue
Normal 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>
|
||||
286
apps/web-antd/src/views/ops/area/modules/form-enhanced.vue
Normal file
286
apps/web-antd/src/views/ops/area/modules/form-enhanced.vue
Normal 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>
|
||||
99
apps/web-antd/src/views/ops/area/modules/form.vue
Normal file
99
apps/web-antd/src/views/ops/area/modules/form.vue
Normal 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>
|
||||
Reference in New Issue
Block a user