Merge remote-tracking branch 'yudao/dev' into dev

This commit is contained in:
jason
2026-01-21 23:28:00 +08:00
32 changed files with 909 additions and 362 deletions

View File

@@ -464,8 +464,6 @@ function handleRenameSuccess() {
>
<div class="flex h-12 items-center">
<!-- 头部分类名 -->
<!-- 2拖动后直接请求排序不用有个保存排序模型分类和排序分类里的模型交互有点不同哈
@芋艿 好像 yudao-ui-admin-vue3 交互也是这样的需要改吗? -->
<div class="flex items-center">
<Tooltip v-if="isCategorySorting" title="拖动排序">
<!-- drag-handle 标识可以拖动不能删掉 -->

View File

@@ -168,10 +168,6 @@ async function initProcessInfo(row: any, formVariables?: any) {
await router.push({
path: row.formCustomCreatePath,
});
// 返回选择流程
// 这里为啥要有个 cancel 事件哈?目前看 vue3 + element-plus 貌似不需要呀;
// @芋艿 不加貌似会有点问题。
emit('cancel');
}
}

View File

@@ -26,8 +26,17 @@ const formData = reactive<InfraCodegenApi.CodegenCreateListReqVO>({
tableNames: [], // 已选择的表列表
});
/** 处理选择变化 */
function handleCheckboxChange({
records,
}: {
records: InfraCodegenApi.DatabaseTable[];
}) {
formData.tableNames = records.map((item) => item.name);
}
/** 表格实例 */
const [Grid] = useVbenVxeGrid({
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useImportTableFormSchema(),
submitOnChange: true,
@@ -67,13 +76,8 @@ const [Grid] = useVbenVxeGrid({
},
} as VxeTableGridOptions<InfraCodegenApi.DatabaseTable>,
gridEvents: {
checkboxChange: ({
records,
}: {
records: InfraCodegenApi.DatabaseTable[];
}) => {
formData.tableNames = records.map((item) => item.name);
},
checkboxChange: handleCheckboxChange,
checkboxAll: handleCheckboxChange,
},
});
@@ -81,6 +85,13 @@ const [Grid] = useVbenVxeGrid({
const [Modal, modalApi] = useVbenModal({
title: '导入表',
class: 'w-1/2',
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
// 关闭时清空选择状态
formData.tableNames = [];
await gridApi.grid?.clearCheckboxRow();
}
},
async onConfirm() {
modalApi.lock();
// 1.1 获取表单值

View File

@@ -1,7 +1,7 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DeviceTypeEnum, DICT_TYPE, LocationTypeEnum } from '@vben/constants';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
@@ -133,16 +133,6 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
.optional()
.or(z.literal('')),
},
{
fieldName: 'locationType',
label: '定位类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_LOCATION_TYPE, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
},
{
fieldName: 'longitude',
label: '设备经度',
@@ -150,11 +140,16 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
componentProps: {
placeholder: '请输入设备经度',
class: 'w-full',
min: -180,
max: 180,
precision: 6,
},
dependencies: {
triggerFields: ['locationType'],
show: (values) => values.locationType === LocationTypeEnum.MANUAL,
},
rules: z
.number()
.min(-180, '经度范围为 -180 到 180')
.max(180, '经度范围为 -180 到 180')
.optional()
.nullable(),
},
{
fieldName: 'latitude',
@@ -163,11 +158,16 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
componentProps: {
placeholder: '请输入设备纬度',
class: 'w-full',
min: -90,
max: 90,
precision: 6,
},
dependencies: {
triggerFields: ['locationType'],
show: (values) => values.locationType === LocationTypeEnum.MANUAL,
},
rules: z
.number()
.min(-90, '纬度范围为 -90 到 90')
.max(90, '纬度范围为 -90 到 90')
.optional()
.nullable(),
},
];
}

View File

@@ -31,7 +31,7 @@ const router = useRouter();
const id = Number(route.params.id);
const loading = ref(true);
const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
const device = ref<IotDeviceApi.DeviceRespVO>({} as IotDeviceApi.DeviceRespVO);
const device = ref<IotDeviceApi.Device>({} as IotDeviceApi.Device);
const activeTab = ref('info');
const thingModelList = ref<ThingModelData[]>([]);

View File

@@ -12,7 +12,7 @@ import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
defineOptions({ name: 'DeviceDetailConfig' });
const props = defineProps<{
device: IotDeviceApi.DeviceRespVO;
device: IotDeviceApi.Device;
}>();
const emit = defineEmits<{
@@ -114,7 +114,7 @@ async function updateDeviceConfig() {
await updateDevice({
id: props.device.id,
config: JSON.stringify(config.value),
} as IotDeviceApi.DeviceSaveReqVO);
} as IotDeviceApi.Device);
message.success({ content: '更新成功!' });
// 触发 success 事件
emit('success');

View File

@@ -12,7 +12,7 @@ import DeviceForm from '../../modules/form.vue';
interface Props {
product: IotProductApi.Product;
device: IotDeviceApi.DeviceRespVO;
device: IotDeviceApi.Device;
loading?: boolean;
}
@@ -50,7 +50,7 @@ function goToProductDetail(productId: number | undefined) {
}
/** 打开编辑表单 */
function openEditForm(row: IotDeviceApi.DeviceRespVO) {
function openEditForm(row: IotDeviceApi.Device) {
formModalApi.setData(row).open();
}
</script>

View File

@@ -11,20 +11,19 @@ import { formatDateTime } from '@vben/utils';
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
message,
Modal,
Row,
} from 'ant-design-vue';
import { getDeviceAuthInfo } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
import { MapDialog } from '#/components/map';
interface Props {
device: IotDeviceApi.DeviceRespVO;
device: IotDeviceApi.Device;
product: IotProductApi.Product;
}
@@ -35,12 +34,18 @@ const authPasswordVisible = ref(false);
const authInfo = ref<IotDeviceApi.DeviceAuthInfoRespVO>(
{} as IotDeviceApi.DeviceAuthInfoRespVO,
);
const mapDialogRef = ref<InstanceType<typeof MapDialog>>();
/** 控制地图显示的标志 */
const showMap = computed(() => {
/** 是否有位置信息 */
const hasLocation = computed(() => {
return !!(props.device.longitude && props.device.latitude);
});
/** 打开地图弹窗 */
function openMapDialog() {
mapDialogRef.value?.open(props.device.longitude, props.device.latitude);
}
/** 复制到剪贴板 */
async function copyToClipboard(text: string) {
try {
@@ -67,106 +72,63 @@ function handleAuthInfoDialogClose() {
authDialogVisible.value = false;
}
</script>
<template>
<div>
<Row :gutter="16">
<!-- 左侧设备信息 -->
<Col :span="12">
<Card class="h-full">
<template #title>
<div class="flex items-center">
<IconifyIcon class="mr-2 text-primary" icon="lucide:info" />
<span>设备信息</span>
</div>
<Card title="设备信息">
<Descriptions :column="3" bordered size="small">
<Descriptions.Item label="产品名称">
{{ product.name }}
</Descriptions.Item>
<Descriptions.Item label="ProductKey">
{{ product.productKey }}
</Descriptions.Item>
<Descriptions.Item label="设备类型">
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="product.deviceType"
/>
</Descriptions.Item>
<Descriptions.Item label="DeviceName">
{{ device.deviceName }}
</Descriptions.Item>
<Descriptions.Item label="备注名称">
{{ device.nickname || '--' }}
</Descriptions.Item>
<Descriptions.Item label="当前状态">
<DictTag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="device.state" />
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDateTime(device.createTime) }}
</Descriptions.Item>
<Descriptions.Item label="激活时间">
{{ formatDateTime(device.activeTime) }}
</Descriptions.Item>
<Descriptions.Item label="最后上线时间">
{{ formatDateTime(device.onlineTime) }}
</Descriptions.Item>
<Descriptions.Item label="最后离线时间">
{{ formatDateTime(device.offlineTime) }}
</Descriptions.Item>
<Descriptions.Item label="设备位置">
<template v-if="hasLocation">
<span class="mr-2">
{{ device.longitude }}, {{ device.latitude }}
</span>
<Button type="link" size="small" @click="openMapDialog">
<IconifyIcon icon="lucide:map-pin" class="mr-1" />
查看地图
</Button>
</template>
<Descriptions :column="1" bordered size="small">
<Descriptions.Item label="产品名称">
{{ props.product.name }}
</Descriptions.Item>
<Descriptions.Item label="ProductKey">
{{ props.product.productKey }}
</Descriptions.Item>
<Descriptions.Item label="设备类型">
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="props.product.deviceType"
/>
</Descriptions.Item>
<Descriptions.Item label="定位类型">
<DictTag
:type="DICT_TYPE.IOT_LOCATION_TYPE"
:value="props.product.locationType"
/>
</Descriptions.Item>
<Descriptions.Item label="DeviceName">
{{ props.device.deviceName }}
</Descriptions.Item>
<Descriptions.Item label="备注名称">
{{ props.device.nickname || '--' }}
</Descriptions.Item>
<Descriptions.Item label="当前状态">
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="props.device.state"
/>
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDateTime(props.device.createTime) }}
</Descriptions.Item>
<Descriptions.Item label="激活时间">
{{ formatDateTime(props.device.activeTime) }}
</Descriptions.Item>
<Descriptions.Item label="最后上线时间">
{{ formatDateTime(props.device.onlineTime) }}
</Descriptions.Item>
<Descriptions.Item label="最后离线时间">
{{ formatDateTime(props.device.offlineTime) }}
</Descriptions.Item>
<Descriptions.Item label="MQTT 连接参数">
<Button
size="small"
type="link"
@click="handleAuthInfoDialogOpen"
>
查看
</Button>
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<!-- 右侧地图 -->
<Col :span="12">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center">
<IconifyIcon class="mr-2 text-primary" icon="lucide:map-pin" />
<span>设备位置</span>
</div>
<div class="text-sm text-gray-500">
最后上线{{ formatDateTime(props.device.onlineTime) || '--' }}
</div>
</div>
</template>
<div class="h-[500px] w-full">
<div
v-if="showMap"
class="flex h-full w-full items-center justify-center rounded bg-gray-100"
>
<span class="text-gray-400">地图组件</span>
</div>
<div
v-else
class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400"
>
<IconifyIcon class="mr-2" icon="lucide:alert-triangle" />
<span>暂无位置信息</span>
</div>
</div>
</Card>
</Col>
</Row>
<span v-else class="text-gray-400">暂无位置信息</span>
</Descriptions.Item>
<Descriptions.Item label="MQTT 连接参数">
<Button size="small" type="link" @click="handleAuthInfoDialogOpen">
查看
</Button>
</Descriptions.Item>
</Descriptions>
</Card>
<!-- 认证信息弹框 -->
<Modal
@@ -226,5 +188,8 @@ function handleAuthInfoDialogClose() {
<Button @click="handleAuthInfoDialogClose">关闭</Button>
</div>
</Modal>
<!-- 地图弹窗 -->
<MapDialog ref="mapDialogRef" />
</div>
</template>

View File

@@ -9,7 +9,6 @@ import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DeviceStateEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
@@ -26,6 +25,7 @@ import {
import { sendDeviceMessage } from '#/api/iot/device/device';
import {
DeviceStateEnum,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
@@ -34,7 +34,7 @@ import DataDefinition from '../../../../thingmodel/modules/components/data-defin
import DeviceDetailsMessage from './message.vue';
const props = defineProps<{
device: IotDeviceApi.DeviceRespVO;
device: IotDeviceApi.Device;
product: IotProductApi.Product;
thingModelList: ThingModelData[];
}>();

View File

@@ -1,17 +1,17 @@
<script lang="ts" setup>
import type { PageParam } from '@vben/request';
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { onMounted, reactive, ref, watch } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { Button, Input, Select, Space } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDevicePage } from '#/api/iot/device/device';
@@ -25,10 +25,6 @@ const props = defineProps<Props>();
const router = useRouter();
const products = ref<IotProductApi.Product[]>([]); // 产品列表
const queryParams = reactive({
deviceName: '',
status: undefined as number | undefined,
}); // 查询参数
function useGridColumns(): VxeTableGridOptions['columns'] {
return [
@@ -72,7 +68,35 @@ function useGridColumns(): VxeTableGridOptions['columns'] {
];
}
const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
/** 搜索表单 schema */
function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'deviceName',
label: 'DeviceName',
component: 'Input',
componentProps: {
placeholder: '请输入 DeviceName',
allowClear: true,
},
},
{
fieldName: 'status',
label: '设备状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number'),
placeholder: '请选择设备状态',
allowClear: true,
},
},
];
}
const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.Device>({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
@@ -82,11 +106,14 @@ const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
},
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
query: async (
{
page,
}: {
page: { currentPage: number; pageSize: number };
},
formValues?: { deviceName?: string; status?: number },
) => {
if (!props.deviceId) {
return { list: [], total: 0 };
}
@@ -95,15 +122,15 @@ const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
pageSize: page.pageSize,
gatewayId: props.deviceId,
deviceType: DeviceTypeEnum.GATEWAY_SUB,
deviceName: queryParams.deviceName || undefined,
status: queryParams.status,
} as IotDeviceApi.DevicePageReqVO);
deviceName: formValues?.deviceName || undefined,
status: formValues?.status,
} as PageParam);
},
},
},
toolbarConfig: {
refresh: true,
search: false,
search: true,
},
pagerConfig: {
enabled: true,
@@ -111,18 +138,6 @@ const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
},
});
/** 搜索操作 */
function handleQuery() {
gridApi.query();
}
/** 重置搜索 */
function resetQuery() {
queryParams.deviceName = '';
queryParams.status = undefined;
handleQuery();
}
/** 获取产品名称 */
function getProductName(productId: number) {
const product = products.value.find((p) => p.id === productId);
@@ -139,7 +154,7 @@ watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
gridApi.query();
}
},
);
@@ -151,49 +166,13 @@ onMounted(async () => {
// 如果设备ID存在则查询列表
if (props.deviceId) {
handleQuery();
gridApi.query();
}
});
</script>
<template>
<Page auto-content-height>
<!-- 搜索区域 -->
<!-- TODO @haohao这个 search 能不能融合到 Grid -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<Input
v-model:value="queryParams.deviceName"
placeholder="请输入设备名称"
style="width: 200px"
allow-clear
@press-enter="handleQuery"
/>
<Select
v-model:value="queryParams.status"
allow-clear
placeholder="请选择设备状态"
style="width: 160px"
>
<Select.Option
v-for="dict in getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number')"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
<Space>
<Button type="primary" @click="handleQuery">
<IconifyIcon icon="ep:search" class="mr-5px" />
搜索
</Button>
<Button @click="resetQuery">
<IconifyIcon icon="ep:refresh-right" class="mr-5px" />
重置
</Button>
</Space>
</div>
<!-- 子设备列表 -->
<Grid>
<template #product="{ row }">

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { PageParam } from '@vben/request';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
import type { IotProductApi } from '#/api/iot/product/product';
@@ -68,7 +70,7 @@ const [DeviceImportFormModal, deviceImportFormModalApi] = useVbenModal({
destroyOnClose: true,
});
const queryParams = ref<Partial<IotDeviceApi.DevicePageReqVO>>({
const queryParams = ref<Partial<PageParam>>({
deviceName: '',
nickname: '',
productId: undefined,
@@ -118,7 +120,7 @@ async function handleExport() {
...queryParams.value,
pageNo: 1,
pageSize: 999_999,
} as IotDeviceApi.DevicePageReqVO);
} as PageParam);
downloadFileFromBlobPart({ fileName: '物联网设备.xls', source: data });
}
@@ -147,12 +149,12 @@ function handleCreate() {
}
/** 编辑设备 */
function handleEdit(row: IotDeviceApi.DeviceRespVO) {
function handleEdit(row: IotDeviceApi.Device) {
deviceFormModalApi.setData(row).open();
}
/** 删除设备 */
async function handleDelete(row: IotDeviceApi.DeviceRespVO) {
async function handleDelete(row: IotDeviceApi.Device) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.deviceName]),
duration: 0,
@@ -203,12 +205,12 @@ function handleImport() {
function handleRowCheckboxChange({
records,
}: {
records: IotDeviceApi.DeviceRespVO[];
records: IotDeviceApi.Device[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.Device>({
gridOptions: {
checkboxConfig: {
highlight: true,
@@ -228,7 +230,7 @@ const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
pageNo: page.currentPage,
pageSize: page.pageSize,
...queryParams.value,
} as IotDeviceApi.DevicePageReqVO);
} as PageParam);
},
},
},

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import type { PageParam } from '@vben/request';
import type { IotDeviceApi } from '#/api/iot/device/device';
import { onMounted, ref } from 'vue';
@@ -46,9 +48,9 @@ const emit = defineEmits<{
}>();
const loading = ref(false);
const list = ref<IotDeviceApi.DeviceRespVO[]>([]);
const list = ref<IotDeviceApi.Device[]>([]);
const total = ref(0);
const queryParams = ref<Partial<IotDeviceApi.DevicePageReqVO>>({
const queryParams = ref<Partial<PageParam>>({
pageNo: 1,
pageSize: 12,
});
@@ -66,7 +68,7 @@ async function getList() {
const data = await getDevicePage({
...queryParams.value,
...props.searchParams,
} as IotDeviceApi.DevicePageReqVO);
} as PageParam);
list.value = data.list || [];
total.value = data.total || 0;
} finally {
@@ -192,7 +194,7 @@ onMounted(() => {
<Button
size="small"
class="action-btn action-btn-detail"
@click="emit('detail', item.id)"
@click="emit('detail', item.id!)"
>
<IconifyIcon icon="lucide:eye" class="mr-1" />
详情
@@ -200,7 +202,7 @@ onMounted(() => {
<Button
size="small"
class="action-btn action-btn-data"
@click="emit('model', item.id)"
@click="emit('model', item.id!)"
>
<IconifyIcon icon="lucide:database" class="mr-1" />
数据

View File

@@ -2,15 +2,16 @@
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, nextTick, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Collapse, message } from 'ant-design-vue';
import { Button, Collapse, message, Space } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
import { MapDialog } from '#/components/map';
import { $t } from '#/locales';
import { useAdvancedFormSchema, useBasicFormSchema } from '../data';
@@ -18,9 +19,10 @@ import { useAdvancedFormSchema, useBasicFormSchema } from '../data';
defineOptions({ name: 'IoTDeviceForm' });
const emit = defineEmits(['success']);
const formData = ref<IotDeviceApi.DeviceRespVO>();
const formData = ref<IotDeviceApi.Device>();
const products = ref<IotProductApi.Product[]>([]);
const activeKey = ref<string[]>([]);
const mapDialogRef = ref<InstanceType<typeof MapDialog>>();
const getTitle = computed(() => {
return formData.value?.id
@@ -78,12 +80,38 @@ async function getAdvancedFormValues() {
picUrl: formData.value?.picUrl,
groupIds: formData.value?.groupIds,
serialNumber: formData.value?.serialNumber,
locationType: formData.value?.locationType,
longitude: formData.value?.longitude,
latitude: formData.value?.latitude,
};
}
/** 打开地图选择弹窗 */
async function openMapDialog() {
// 如果高级表单未挂载,先展开 Collapse
if (!advancedFormApi.isMounted) {
activeKey.value = ['advanced'];
await nextTick();
await nextTick();
}
const values = await advancedFormApi.getValues();
mapDialogRef.value?.open(
values.longitude ? Number(values.longitude) : undefined,
values.latitude ? Number(values.latitude) : undefined,
);
}
/** 处理地图选择确认 */
async function handleMapConfirm(data: {
address: string;
latitude: string;
longitude: string;
}) {
if (advancedFormApi.isMounted) {
await advancedFormApi.setFieldValue('longitude', Number(data.longitude));
await advancedFormApi.setFieldValue('latitude', Number(data.latitude));
}
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
@@ -97,7 +125,7 @@ const [Modal, modalApi] = useVbenModal({
const data = {
...basicValues,
...advancedValues,
} as IotDeviceApi.DeviceSaveReqVO;
} as IotDeviceApi.Device;
try {
await (formData.value?.id ? updateDevice(data) : createDevice(data));
// 关闭并提示
@@ -115,11 +143,8 @@ const [Modal, modalApi] = useVbenModal({
return;
}
// 加载数据
const data = modalApi.getData<IotDeviceApi.DeviceRespVO>();
const data = modalApi.getData<IotDeviceApi.Device>();
if (!data || !data.id) {
// 新增:确保 Collapse 折叠
// TODO @haohao是不是 activeKey 在上面的 112 到 115 就已经处理了哈;
activeKey.value = [];
return;
}
// 编辑模式:加载数据
@@ -127,29 +152,29 @@ const [Modal, modalApi] = useVbenModal({
try {
formData.value = await getDevice(data.id);
await formApi.setValues(formData.value);
// 如果存在高级字段数据,自动展开 Collapse
// TODO @haohao默认不用展开哈
if (
formData.value?.nickname ||
formData.value?.picUrl ||
formData.value?.groupIds?.length ||
formData.value?.serialNumber ||
formData.value?.locationType !== undefined
) {
activeKey.value = ['advanced'];
// 等待 Collapse 展开后表单挂载
await nextTick();
await nextTick();
if (advancedFormApi.isMounted) {
await advancedFormApi.setValues(formData.value);
}
}
} finally {
modalApi.unlock();
}
},
});
/** 监听 Collapse 展开,自动设置高级表单的值 */
watch(
activeKey,
async (newKeys) => {
// 当用户手动展开 Collapse 且存在表单数据时,设置高级表单的值
if (newKeys.includes('advanced') && formData.value) {
// 等待表单挂载
await nextTick();
await nextTick();
if (advancedFormApi.isMounted) {
await advancedFormApi.setValues(formData.value);
}
}
},
{ immediate: false },
);
/** 初始化产品列表 */
onMounted(async () => {
products.value = await getSimpleProductList();
@@ -163,8 +188,13 @@ onMounted(async () => {
<Collapse v-model:active-key="activeKey" class="mt-4">
<Collapse.Panel key="advanced" header="更多设置">
<AdvancedForm />
<Space class="mt-2">
<Button type="primary" @click="openMapDialog">坐标拾取</Button>
</Space>
</Collapse.Panel>
</Collapse>
</div>
</Modal>
<!-- 地图选择弹窗 -->
<MapDialog ref="mapDialogRef" @confirm="handleMapConfirm" />
</template>

View File

@@ -11,6 +11,7 @@ import { getStatisticsSummary } from '#/api/iot/statistics';
import { defaultStatsData } from './data';
import DeviceCountCard from './modules/device-count-card.vue';
import DeviceMapCard from './modules/device-map-card.vue';
import DeviceStateCountCard from './modules/device-state-count-card.vue';
import MessageTrendCard from './modules/message-trend-card.vue';
@@ -97,10 +98,17 @@ onMounted(() => {
</Row>
<!-- 第三行消息统计 -->
<Row :gutter="16">
<Row :gutter="16" class="mb-4">
<Col :span="24">
<MessageTrendCard />
</Col>
</Row>
<!-- 第四行设备分布地图 -->
<Row :gutter="16">
<Col :span="24">
<DeviceMapCard />
</Col>
</Row>
</Page>
</template>

View File

@@ -0,0 +1,215 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Card, Empty, Spin } from 'ant-design-vue';
import { getDeviceLocationList } from '#/api/iot/device/device';
import { loadBaiduMapSdk } from '#/components/map';
import { DeviceStateEnum } from '#/views/iot/utils/constants';
defineOptions({ name: 'DeviceMapCard' });
const router = useRouter();
const mapContainerRef = ref<HTMLElement>();
let mapInstance: any = null;
const loading = ref(true);
const deviceList = ref<IotDeviceApi.Device[]>([]);
/** 是否有数据 */
const hasData = computed(() => deviceList.value.length > 0);
/** 设备状态颜色映射 */
const stateColorMap: Record<number, string> = {
[DeviceStateEnum.INACTIVE]: '#EAB308', // 待激活 - 黄色
[DeviceStateEnum.ONLINE]: '#22C55E', // 在线 - 绿色
[DeviceStateEnum.OFFLINE]: '#9CA3AF', // 离线 - 灰色
};
/** 获取设备状态配置 */
function getStateConfig(state: number): { color: string; name: string } {
const stateNames: Record<number, string> = {
[DeviceStateEnum.INACTIVE]: '待激活',
[DeviceStateEnum.ONLINE]: '在线',
[DeviceStateEnum.OFFLINE]: '离线',
};
return {
name: stateNames[state] || '未知',
color: stateColorMap[state] || '#909399',
};
}
/** 创建自定义标记点图标 */
function createMarkerIcon(color: string, isOnline: boolean) {
const size = isOnline ? 24 : 20;
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8" fill="${color}" stroke="white" stroke-width="2"/>
${isOnline ? `<circle cx="12" cy="12" r="10" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : ''}
</svg>
`;
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
return new window.BMapGL.Icon(url, new window.BMapGL.Size(size, size), {
anchor: new window.BMapGL.Size(size / 2, size / 2),
});
}
/** 初始化地图 */
function initMap() {
if (!mapContainerRef.value || !window.BMapGL) {
return;
}
// 销毁旧实例
if (mapInstance) {
mapInstance.destroy?.();
mapInstance = null;
}
// 创建地图实例,默认以中国为中心
mapInstance = new window.BMapGL.Map(mapContainerRef.value);
mapInstance.centerAndZoom(new window.BMapGL.Point(106, 37.5), 5);
mapInstance.enableScrollWheelZoom();
// 添加控件
mapInstance.addControl(new window.BMapGL.ScaleControl());
mapInstance.addControl(new window.BMapGL.ZoomControl());
// 添加设备标记点
deviceList.value.forEach((device) => {
const config = getStateConfig(device.state!);
const isOnline = device.state === DeviceStateEnum.ONLINE;
const point = new window.BMapGL.Point(device.longitude, device.latitude);
// 创建标记
const marker = new window.BMapGL.Marker(point, {
icon: createMarkerIcon(config.color, isOnline),
});
// 创建信息窗口内容
const infoContent = `
<div style="padding: 8px; min-width: 180px;">
<div style="font-weight: bold; margin-bottom: 8px; font-size: 14px;">${device.nickname || device.deviceName}</div>
<div style="color: #666; font-size: 12px; line-height: 1.8;">
<div>产品: ${device.productName || '-'}</div>
<div>状态: <span style="color: ${config.color}; font-weight: 500;">${config.name}</span></div>
</div>
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #eee;">
<a href="javascript:void(0)" class="device-link" data-id="${device.id}" style="color: #1890ff; font-size: 12px; text-decoration: none;">点击查看详情 →</a>
</div>
</div>
`;
// 点击标记显示信息窗口
marker.addEventListener('click', () => {
const infoWindow = new window.BMapGL.InfoWindow(infoContent, {
width: 220,
height: 140,
title: '',
});
// 信息窗口打开后绑定链接点击事件
infoWindow.addEventListener('open', () => {
setTimeout(() => {
const link = document.querySelector('.device-link');
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
const deviceId = e.target as HTMLElement.dataset.id;
if (deviceId) {
router.push({
name: 'IoTDeviceDetail',
params: { id: deviceId },
});
}
});
}
}, 100);
});
mapInstance.openInfoWindow(infoWindow, point);
});
mapInstance.addOverlay(marker);
});
}
/** 加载设备数据 */
async function loadDeviceData() {
loading.value = true;
try {
deviceList.value = await getDeviceLocationList();
} finally {
loading.value = false;
}
}
/** 初始化 */
async function init() {
await loadDeviceData();
if (!hasData.value) {
return;
}
await loadBaiduMapSdk();
initMap();
}
/** 组件挂载时初始化 */
onMounted(() => {
init();
});
/** 组件卸载时销毁地图实例 */
onUnmounted(() => {
if (mapInstance) {
mapInstance.destroy?.();
mapInstance = null;
}
});
</script>
<template>
<Card class="h-full" title="设备分布地图">
<template #extra>
<div class="flex items-center gap-4 text-sm">
<span class="flex items-center gap-1">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{ backgroundColor: stateColorMap[DeviceStateEnum.ONLINE] }"
></span>
<span class="text-gray-500">在线</span>
</span>
<span class="flex items-center gap-1">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{ backgroundColor: stateColorMap[DeviceStateEnum.OFFLINE] }"
></span>
<span class="text-gray-500">离线</span>
</span>
<span class="flex items-center gap-1">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{
backgroundColor: stateColorMap[DeviceStateEnum.INACTIVE],
}"
></span>
<span class="text-gray-500">待激活</span>
</span>
</div>
</template>
<Spin v-if="loading" class="flex h-[500px] items-center justify-center" />
<Empty
v-else-if="!hasData"
class="h-[500px]"
description="暂无设备位置数据"
/>
<div
v-show="hasData && !loading"
ref="mapContainerRef"
class="h-[500px] w-full"
></div>
</Card>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { DeviceRespVO, IotDeviceApi } from '#/api/iot/device/device';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { OtaTask } from '#/api/iot/ota/task';
import { computed, ref } from 'vue';
@@ -57,7 +57,7 @@ const formRules = {
},
],
};
const devices = ref<IotDeviceApi.DeviceRespVO[]>([]);
const devices = ref<IotDeviceApi.Device[]>([]);
/** 设备选项 */
const deviceOptions = computed(() => {

View File

@@ -137,6 +137,7 @@ export function useBasicFormSchema(
},
rules: 'required',
},
// TODO @haohao这个貌似不需要
{
fieldName: 'status',
label: '产品状态',
@@ -149,16 +150,6 @@ export function useBasicFormSchema(
defaultValue: 0,
rules: 'required',
},
{
fieldName: 'locationType',
label: '定位类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_LOCATION_TYPE, 'number'),
placeholder: '请选择定位类型',
},
rules: 'required',
},
];
}

View File

@@ -35,9 +35,6 @@ function formatDate(date?: Date | string) {
:value="product.deviceType"
/>
</Descriptions.Item>
<Descriptions.Item label="定位类型">
{{ product.locationType ?? '-' }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDate(product.createTime) }}
</Descriptions.Item>

View File

@@ -8,6 +8,13 @@ export const IOT_PROVIDE_KEY = {
PRODUCT: 'IOT_PRODUCT',
};
/** IoT 设备状态枚举 */
export enum DeviceStateEnum {
INACTIVE = 0, // 未激活
OFFLINE = 2, // 离线
ONLINE = 1, // 在线
}
/** IoT 产品物模型类型枚举类 */
export const IoTThingModelTypeEnum = {
PROPERTY: 1, // 属性