diff --git a/.vscode/settings.json b/.vscode/settings.json index a45451b07..588357bba 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -181,7 +181,8 @@ "markdown", "json", "jsonc", - "json5" + "json5", + "yaml" ], "tailwindCSS.experimental.classRegex": [ diff --git a/README.md b/README.md index ba7654879..74f870e4f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ## 🐶 新手必读 -- nodejs > 20.12.0 && pnpm > 10.22.0 (强制使用pnpm) +- nodejs > 20.19.0 && pnpm > 10.28.1 (强制使用pnpm) - 演示地址【Vue3 + element-plus】: - 演示地址【Vue3 + vben5(ant-design-vue)】: - 演示地址【Vue2 + element-ui】: @@ -41,26 +41,26 @@ | 框架 | 说明 | 版本 | | --- | --- | --- | -| [Vue](https://staging-cn.vuejs.org/) | vue框架 | 3.5.24 | -| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 7.2.2 | +| [Vue](https://staging-cn.vuejs.org/) | vue框架 | 3.5.27 | +| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 7.3.1 | | [Ant Design Vue](https://www.antdv.com/) | Ant Design Vue | 4.2.6 | -| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.10.2 | -| [Naive UI](https://www.naiveui.com/) | Naive UI | 2.42.0 | -| [TDesign](https://tdesign.tencent.com/) | TDesign | 1.17.1 | +| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.13.1 | +| [Naive UI](https://www.naiveui.com/) | Naive UI | 2.43.2 | +| [TDesign](https://tdesign.tencent.com/) | TDesign | 1.18.0 | | [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 超集 | 5.9.3 | -| [pinia](https://pinia.vuejs.org/) | Vue 存储库替代 vuex5 | 3.0.3 | -| [vueuse](https://vueuse.org/) | 常用工具集 | 13.4.0 | -| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 11.1.7 | -| [vue-router](https://router.vuejs.org/) | Vue 路由 | 4.5.1 | -| [Tailwind CSS](https://tailwindcss.com/) | 原子 CSS | 3.4.18 | +| [pinia](https://pinia.vuejs.org/) | Vue 存储库替代 vuex5 | 3.0.4 | +| [vueuse](https://vueuse.org/) | 常用工具集 | 14.1.0 | +| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 11.2.8 | +| [vue-router](https://router.vuejs.org/) | Vue 路由 | 4.6.4 | +| [Tailwind CSS](https://tailwindcss.com/) | 原子 CSS | 3.4.19 | | [Iconify](https://iconify.design/) | 图标组件 | 5.0.0 | -| [Iconify](https://icon-sets.iconify.design/) | 在线图标库 | 2.2.406 | -| [TinyMCE](https://www.tiny.cloud/) | 富文本编辑器 | 6.1.0 | +| [Iconify](https://icon-sets.iconify.design/) | 在线图标库 | 2.2.431 | +| [TinyMCE](https://www.tiny.cloud/) | 富文本编辑器 | 7.3.0 | | [Echarts](https://echarts.apache.org/) | 图表库 | 6.0.0 | -| [axios](https://axios-http.com/) | http客户端 | 1.10.0 | -| [dayjs](https://day.js.org/) | 日期处理库 | 1.11.13 | +| [axios](https://axios-http.com/) | http客户端 | 1.13.2 | +| [dayjs](https://day.js.org/) | 日期处理库 | 1.11.19 | | [vee-validate](https://vee-validate.logaretm.com/) | 表单验证 | 4.15.1 | -| [zod](https://zod.dev/) | 数据验证 | 3.25.67 | +| [zod](https://zod.dev/) | 数据验证 | 3.25.76 | ## 🔥 后端架构 diff --git a/apps/web-antd/.env b/apps/web-antd/.env index 778a9fa1f..a0bfde11e 100644 --- a/apps/web-antd/.env +++ b/apps/web-antd/.env @@ -33,3 +33,6 @@ VITE_APP_API_ENCRYPT_REQUEST_KEY = 52549111389893486934626385991395 VITE_APP_API_ENCRYPT_RESPONSE_KEY = 96103715984234343991809655248883 # VITE_APP_API_ENCRYPT_REQUEST_KEY = MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCls2rIpnGdYnLFgz1XU13GbNQ5DloyPpvW00FPGjqn5Z6JpK+kDtVlnkhwR87iRrE5Vf2WNqRX6vzbLSgveIQY8e8oqGCb829myjf1MuI+ZzN4ghf/7tEYhZJGPI9AbfxFqBUzm+kR3/HByAI22GLT96WM26QiMK8n3tIP/yiLswIDAQAB # VITE_APP_API_ENCRYPT_RESPONSE_KEY = MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAOH8IfIFxL/MR9XIg1UDv5z1fGXQI93E8wrU4iPFovL/sEt9uSgSkjyidC2O7N+m7EKtoN6b1u7cEwXSkwf3kfK0jdWLSQaNpX5YshqXCBzbDfugDaxuyYrNA4/tIMs7mzZAk0APuRXB35Dmupou7Yw7TFW/7QhQmGfzeEKULQvnAgMBAAECgYAw8LqlQGyQoPv5p3gRxEMOCfgL0JzD3XBJKztiRd35RDh40Nx1ejgjW4dPioFwGiVWd2W8cAGHLzALdcQT2KDJh+T/tsd4SPmI6uSBBK6Ff2DkO+kFFcuYvfclQQKqxma5CaZOSqhgenacmgTMFeg2eKlY3symV6JlFNu/IKU42QJBAOhxAK/Eq3e61aYQV2JSguhMR3b8NXJJRroRs/QHEanksJtl+M+2qhkC9nQVXBmBkndnkU/l2tYcHfSBlAyFySMCQQD445tgm/J2b6qMQmuUGQAYDN8FIkHjeKmha+l/fv0igWm8NDlBAem91lNDIPBUzHL1X1+pcts5bjmq99YdOnhtAkAg2J8dN3B3idpZDiQbC8fd5bGPmdI/pSUudAP27uzLEjr2qrE/QPPGdwm2m7IZFJtK7kK1hKio6u48t/bg0iL7AkEAuUUs94h+v702Fnym+jJ2CHEkXvz2US8UDs52nWrZYiM1o1y4tfSHm8H8bv8JCAa9GHyriEawfBraILOmllFdLQJAQSRZy4wmlaG48MhVXodB85X+VZ9krGXZ2TLhz7kz9iuToy53l9jTkESt6L5BfBDCVdIwcXLYgK+8KFdHN5W7HQ== + +# 百度地图 +VITE_BAIDU_MAP_KEY=Y2aJXiswwPxy6mwFs1z9c7U5gwX9WfUN \ No newline at end of file diff --git a/apps/web-antd/src/adapter/component/index.ts b/apps/web-antd/src/adapter/component/index.ts index a62b88065..5bac14d67 100644 --- a/apps/web-antd/src/adapter/component/index.ts +++ b/apps/web-antd/src/adapter/component/index.ts @@ -3,6 +3,8 @@ * 可用于 vben-form、vben-modal、vben-drawer 等组件使用, */ +/* eslint-disable vue/one-component-per-file */ + import type { UploadChangeParam, UploadFile, @@ -15,6 +17,7 @@ import type { BaseFormComponentType } from '@vben/common-ui'; import type { Recordable } from '@vben/types'; import { + computed, defineAsyncComponent, defineComponent, h, @@ -24,12 +27,17 @@ import { watch, } from 'vue'; -import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui'; +import { + ApiComponent, + globalShareState, + IconPicker, + VCropper, +} from '@vben/common-ui'; import { IconifyIcon } from '@vben/icons'; import { $t } from '@vben/locales'; import { isEmpty } from '@vben/utils'; -import { notification } from 'ant-design-vue'; +import { message, Modal, notification } from 'ant-design-vue'; import { Tinymce as RichTextarea } from '#/components/tinymce'; import { FileUpload, ImageUpload } from '#/components/upload'; @@ -125,9 +133,249 @@ const withDefaultPlaceholder = ( }; const withPreviewUpload = () => { + // 检查是否为图片文件的辅助函数 + const isImageFile = (file: UploadFile): boolean => { + const imageExtensions = new Set([ + 'bmp', + 'gif', + 'jpeg', + 'jpg', + 'png', + 'svg', + 'webp', + ]); + if (file.url) { + try { + const pathname = new URL(file.url, 'http://localhost').pathname; + const ext = pathname.split('.').pop()?.toLowerCase(); + return ext ? imageExtensions.has(ext) : false; + } catch { + const ext = file.url?.split('.').pop()?.toLowerCase(); + return ext ? imageExtensions.has(ext) : false; + } + } + if (!file.type) { + const ext = file.name?.split('.').pop()?.toLowerCase(); + return ext ? imageExtensions.has(ext) : false; + } + return file.type.startsWith('image/'); + }; + // 创建默认的上传按钮插槽 + const createDefaultSlotsWithUpload = ( + listType: string, + placeholder: string, + ) => { + switch (listType) { + case 'picture-card': { + return { + default: () => placeholder, + }; + } + default: { + return { + default: () => + h( + Button, + { + icon: h(IconifyIcon, { + icon: 'ant-design:upload-outlined', + class: 'mb-1 size-4', + }), + }, + () => placeholder, + ), + }; + } + } + }; + // 构建预览图片组 + const previewImage = async ( + file: UploadFile, + visible: Ref, + fileList: Ref, + ) => { + // 如果当前文件不是图片,直接打开 + if (!isImageFile(file)) { + if (file.url) { + window.open(file.url, '_blank'); + } else if (file.preview) { + window.open(file.preview, '_blank'); + } else { + message.error($t('ui.formRules.previewWarning')); + } + return; + } + + // 对于图片文件,继续使用预览组 + const [ImageComponent, PreviewGroupComponent] = await Promise.all([ + Image, + PreviewGroup, + ]); + + const getBase64 = (file: File) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.addEventListener('load', () => resolve(reader.result)); + reader.addEventListener('error', (error) => reject(error)); + }); + }; + // 从fileList中过滤出所有图片文件 + const imageFiles = (unref(fileList) || []).filter((element) => + isImageFile(element), + ); + + // 为所有没有预览地址的图片生成预览 + for (const imgFile of imageFiles) { + if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) { + imgFile.preview = (await getBase64(imgFile.originFileObj)) as string; + } + } + const container: HTMLElement | null = document.createElement('div'); + document.body.append(container); + + // 用于追踪组件是否已卸载 + let isUnmounted = false; + + const PreviewWrapper = { + setup() { + return () => { + if (isUnmounted) return null; + return h( + PreviewGroupComponent, + { + class: 'hidden', + preview: { + visible: visible.value, + // 设置初始显示的图片索引 + current: imageFiles.findIndex((f) => f.uid === file.uid), + onVisibleChange: (value: boolean) => { + visible.value = value; + if (!value) { + // 延迟清理,确保动画完成 + setTimeout(() => { + if (!isUnmounted && container) { + isUnmounted = true; + render(null, container); + container.remove(); + } + }, 300); + } + }, + }, + }, + () => + // 渲染所有图片文件 + imageFiles.map((imgFile) => + h(ImageComponent, { + key: imgFile.uid, + src: imgFile.url || imgFile.preview, + }), + ), + ); + }; + }, + }; + + render(h(PreviewWrapper), container); + }; + + // 图片裁剪操作 + const cropImage = (file: File, aspectRatio: string | undefined) => { + return new Promise((resolve, reject) => { + const container: HTMLElement | null = document.createElement('div'); + document.body.append(container); + + // 用于追踪组件是否已卸载 + let isUnmounted = false; + let objectUrl: null | string = null; + + const open = ref(true); + const cropperRef = ref | null>(null); + + const closeModal = () => { + open.value = false; + // 延迟清理,确保动画完成 + setTimeout(() => { + if (!isUnmounted && container) { + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + isUnmounted = true; + render(null, container); + container.remove(); + } + }, 300); + }; + + const CropperWrapper = { + setup() { + return () => { + if (isUnmounted) return null; + if (!objectUrl) { + objectUrl = URL.createObjectURL(file); + } + return h( + Modal, + { + open: open.value, + title: h('div', {}, [ + $t('ui.crop.title'), + h( + 'span', + { + class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`, + }, + $t('ui.crop.titleTip', [aspectRatio]), + ), + ]), + centered: true, + width: 548, + keyboard: false, + maskClosable: false, + closable: false, + cancelText: $t('common.cancel'), + okText: $t('ui.crop.confirm'), + destroyOnClose: true, + onOk: async () => { + const cropper = cropperRef.value; + if (!cropper) { + reject(new Error('Cropper not found')); + closeModal(); + return; + } + try { + const dataUrl = await cropper.getCropImage(); + resolve(dataUrl); + } catch { + reject(new Error($t('ui.crop.errorTip'))); + } finally { + closeModal(); + } + }, + onCancel() { + resolve(''); + closeModal(); + }, + }, + () => + h(VCropper, { + ref: (ref: any) => (cropperRef.value = ref), + img: objectUrl as string, + aspectRatio, + }), + ); + }; + }, + }; + + render(h(CropperWrapper), container); + }); + }; + return defineComponent({ name: Upload.name, - emits: ['change', 'update:modelValue'], + emits: ['update:modelValue'], setup: ( props: any, { attrs, slots, emit }: { attrs: any; emit: any; slots: any }, @@ -142,9 +390,54 @@ const withPreviewUpload = () => { attrs?.fileList || attrs?.['file-list'] || [], ); - const handleChange = async (event: UploadChangeParam) => { - fileList.value = event.fileList; - emit('change', event); + const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']); + const aspectRatio = computed( + () => attrs?.aspectRatio ?? attrs?.['aspect-ratio'], + ); + + const handleBeforeUpload = async ( + file: UploadFile, + originFileList: Array, + ) => { + if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) { + message.error($t('ui.formRules.sizeLimit', [maxSize.value])); + file.status = 'removed'; + return false; + } + // 多选或者非图片不唤起裁剪框 + if ( + attrs.crop && + !attrs.multiple && + originFileList[0] && + isImageFile(file) + ) { + file.status = 'removed'; + // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取 + const blob = await cropImage(originFileList[0], aspectRatio.value); + return new Promise((resolve, reject) => { + if (!blob) { + return reject(new Error($t('ui.crop.errorTip'))); + } + resolve(blob); + }); + } + + return attrs.beforeUpload?.(file) ?? true; + }; + + const handleChange = (event: UploadChangeParam) => { + try { + // 行内写法 handleChange: (event) => {} + attrs.handleChange?.(event); + // template写法 @handle-change="(event) => {}" + attrs.onHandleChange?.(event); + } catch (error) { + // Avoid breaking internal v-model sync on user handler errors + console.error(error); + } + fileList.value = event.fileList.filter( + (file) => file.status !== 'removed', + ); emit( 'update:modelValue', event.fileList?.length ? fileList.value : undefined, @@ -185,6 +478,7 @@ const withPreviewUpload = () => { ...props, ...attrs, fileList: fileList.value, + beforeUpload: handleBeforeUpload, onChange: handleChange, onPreview: handlePreview, }, @@ -194,146 +488,6 @@ const withPreviewUpload = () => { }); }; -const createDefaultSlotsWithUpload = ( - listType: string, - placeholder: string, -) => { - switch (listType) { - case 'picture-card': { - return { - default: () => placeholder, - }; - } - default: { - return { - default: () => - h( - Button, - { - icon: h(IconifyIcon, { - icon: 'ant-design:upload-outlined', - class: 'mb-1 size-4', - }), - }, - () => placeholder, - ), - }; - } - } -}; - -const previewImage = async ( - file: UploadFile, - visible: Ref, - fileList: Ref, -) => { - // 检查是否为图片文件的辅助函数 - const isImageFile = (file: UploadFile): boolean => { - const imageExtensions = new Set([ - 'bmp', - 'gif', - 'jpeg', - 'jpg', - 'png', - 'webp', - ]); - if (file.url) { - const ext = file.url?.split('.').pop()?.toLowerCase(); - return ext ? imageExtensions.has(ext) : false; - } - if (!file.type) { - const ext = file.name?.split('.').pop()?.toLowerCase(); - return ext ? imageExtensions.has(ext) : false; - } - return file.type.startsWith('image/'); - }; - - // 如果当前文件不是图片,直接打开 - if (!isImageFile(file)) { - if (file.url) { - window.open(file.url, '_blank'); - } else if (file.preview) { - window.open(file.preview, '_blank'); - } else { - console.warn('无法打开文件,没有可用的URL或预览地址'); - } - return; - } - - // 对于图片文件,继续使用预览组 - const [ImageComponent, PreviewGroupComponent] = await Promise.all([ - Image, - PreviewGroup, - ]); - - const getBase64 = (file: File) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.addEventListener('load', () => resolve(reader.result)); - reader.addEventListener('error', (error) => reject(error)); - }); - }; - // 从fileList中过滤出所有图片文件 - const imageFiles = (unref(fileList) || []).filter((element) => - isImageFile(element), - ); - - // 为所有没有预览地址的图片生成预览 - for (const imgFile of imageFiles) { - if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) { - imgFile.preview = (await getBase64(imgFile.originFileObj)) as string; - } - } - const container: HTMLElement | null = document.createElement('div'); - document.body.append(container); - - // 用于追踪组件是否已卸载 - let isUnmounted = false; - - const PreviewWrapper = { - setup() { - return () => { - if (isUnmounted) return null; - return h( - PreviewGroupComponent, - { - class: 'hidden', - preview: { - visible: visible.value, - // 设置初始显示的图片索引 - current: imageFiles.findIndex((f) => f.uid === file.uid), - onVisibleChange: (value: boolean) => { - visible.value = value; - if (!value) { - // 延迟清理,确保动画完成 - setTimeout(() => { - if (!isUnmounted && container) { - isUnmounted = true; - render(null, container); - container.remove(); - } - }, 300); - } - }, - }, - }, - () => - // 渲染所有图片文件 - imageFiles.map((imgFile) => - h(ImageComponent, { - key: imgFile.uid, - src: imgFile.url || imgFile.preview, - }), - ), - ); - }; - }, - }; - - render(h(PreviewWrapper), container); -}; - // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明 export type ComponentType = | 'ApiCascader' @@ -374,6 +528,7 @@ async function initComponentAdapter() { // 如果你的组件体积比较大,可以使用异步加载 // Button: () => // import('xxx').then((res) => res.Button), + ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', { component: Cascader, fieldNames: { label: 'label', value: 'value', children: 'children' }, @@ -381,34 +536,20 @@ async function initComponentAdapter() { modelPropName: 'value', visibleEvent: 'onVisibleChange', }), - ApiSelect: withDefaultPlaceholder( - { - ...ApiComponent, - name: 'ApiSelect', - }, - 'select', - { - component: Select, - loadingSlot: 'suffixIcon', - visibleEvent: 'onDropdownVisibleChange', - modelPropName: 'value', - }, - ), - ApiTreeSelect: withDefaultPlaceholder( - { - ...ApiComponent, - name: 'ApiTreeSelect', - }, - 'select', - { - component: TreeSelect, - fieldNames: { label: 'label', value: 'value', children: 'children' }, - loadingSlot: 'suffixIcon', - modelPropName: 'value', - optionsPropName: 'treeData', - visibleEvent: 'onVisibleChange', - }, - ), + ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', { + component: Select, + loadingSlot: 'suffixIcon', + modelPropName: 'value', + visibleEvent: 'onVisibleChange', + }), + ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', { + component: TreeSelect, + fieldNames: { label: 'label', value: 'value', children: 'children' }, + loadingSlot: 'suffixIcon', + modelPropName: 'value', + optionsPropName: 'treeData', + visibleEvent: 'onVisibleChange', + }), AutoComplete, Cascader, Checkbox, diff --git a/apps/web-antd/src/api/iot/device/device/index.ts b/apps/web-antd/src/api/iot/device/device/index.ts index 5e7581274..ca2a17ada 100644 --- a/apps/web-antd/src/api/iot/device/device/index.ts +++ b/apps/web-antd/src/api/iot/device/device/index.ts @@ -3,51 +3,17 @@ import type { PageParam, PageResult } from '@vben/request'; import { requestClient } from '#/api/request'; export namespace IotDeviceApi { - /** 设备新增/修改 Request VO */ - // TODO @haohao:可以降低一些 VO 哈:DeviceSaveReqVO、DeviceRespVO 合并成 Device 就好,类似别的模块 - export interface DeviceSaveReqVO { + /** 设备 */ + export interface Device { id?: number; // 设备编号 deviceName: string; // 设备名称 nickname?: string; // 备注名称 serialNumber?: string; // 设备序列号 picUrl?: string; // 设备图片 groupIds?: number[]; // 设备分组编号数组 - productId: number; // 产品编号(必填) - gatewayId?: number; // 网关设备 ID - config?: string; // 设备配置 - locationType: number; // 定位类型(必填) - latitude?: number; // 设备位置的纬度 - longitude?: number; // 设备位置的经度 - } - - /** 设备更新分组 Request VO */ - export interface DeviceUpdateGroupReqVO { - ids: number[]; // 设备编号列表(必填) - groupIds: number[]; // 分组编号列表(必填) - } - - /** 设备分页 Request VO */ - // TODO @haohao:可以不用 DevicePageReqVO,直接 PageParam 即可,简洁一点。这里的强类型,收益不大; - export interface DevicePageReqVO extends PageParam { - deviceName?: string; // 设备名称 - nickname?: string; // 备注名称 - productId?: number; // 产品编号 - deviceType?: number; // 设备类型 - status?: number; // 设备状态 - groupId?: number; // 设备分组编号 - gatewayId?: number; // 网关设备 ID - } - - /** 设备 Response VO */ - export interface DeviceRespVO { - id: number; // 设备编号 - deviceName: string; // 设备名称 - nickname?: string; // 设备备注名称 - serialNumber?: string; // 设备序列号 - picUrl?: string; // 设备图片 - groupIds?: number[]; // 设备分组编号数组 productId: number; // 产品编号 productKey?: string; // 产品标识 + productName?: string; // 产品名称(只有部分接口返回,例如 getDeviceLocationList) deviceType?: number; // 设备类型 gatewayId?: number; // 网关设备 ID state?: number; // 设备状态 @@ -55,14 +21,18 @@ export namespace IotDeviceApi { offlineTime?: Date; // 最后离线时间 activeTime?: Date; // 设备激活时间 deviceSecret?: string; // 设备密钥,用于设备认证 - authType?: string; // 认证类型(如一机一密、动态注册) config?: string; // 设备配置 - locationType?: number; // 定位方式 latitude?: number; // 设备位置的纬度 longitude?: number; // 设备位置的经度 createTime?: Date; // 创建时间 } + /** 设备更新分组 Request VO */ + export interface DeviceUpdateGroupReqVO { + ids: number[]; // 设备编号列表(必填) + groupIds: number[]; // 分组编号列表(必填) + } + /** 设备认证信息 Response VO */ export interface DeviceAuthInfoRespVO { clientId: string; // 客户端 ID @@ -104,8 +74,8 @@ export namespace IotDeviceApi { } /** 查询设备分页 */ -export function getDevicePage(params: IotDeviceApi.DevicePageReqVO) { - return requestClient.get>( +export function getDevicePage(params: PageParam) { + return requestClient.get>( '/iot/device/page', { params }, ); @@ -113,18 +83,16 @@ export function getDevicePage(params: IotDeviceApi.DevicePageReqVO) { /** 查询设备详情 */ export function getDevice(id: number) { - return requestClient.get( - `/iot/device/get?id=${id}`, - ); + return requestClient.get(`/iot/device/get?id=${id}`); } /** 新增设备 */ -export function createDevice(data: IotDeviceApi.DeviceSaveReqVO) { +export function createDevice(data: IotDeviceApi.Device) { return requestClient.post('/iot/device/create', data); } /** 修改设备 */ -export function updateDevice(data: IotDeviceApi.DeviceSaveReqVO) { +export function updateDevice(data: IotDeviceApi.Device) { return requestClient.put('/iot/device/update', data); } @@ -146,7 +114,7 @@ export function deleteDeviceList(ids: number[]) { } /** 导出设备 */ -export function exportDeviceExcel(params: IotDeviceApi.DevicePageReqVO) { +export function exportDeviceExcel(params: PageParam) { return requestClient.download('/iot/device/export-excel', { params }); } @@ -157,22 +125,21 @@ export function getDeviceCount(productId: number) { /** 获取设备的精简信息列表 */ export function getSimpleDeviceList(deviceType?: number, productId?: number) { - return requestClient.get( - '/iot/device/simple-list', - { - params: { deviceType, productId }, - }, - ); + return requestClient.get('/iot/device/simple-list', { + params: { deviceType, productId }, + }); } /** 根据产品编号,获取设备的精简信息列表 */ export function getDeviceListByProductId(productId: number) { - return requestClient.get( - '/iot/device/simple-list', - { - params: { productId }, - }, - ); + return requestClient.get('/iot/device/simple-list', { + params: { productId }, + }); +} + +/** 获取设备位置列表(用于地图展示) */ +export function getDeviceLocationList() { + return requestClient.get('/iot/device/location-list'); } /** 获取导入模板 */ @@ -233,3 +200,35 @@ export function getDeviceMessagePairPage(params: PageParam) { export function sendDeviceMessage(params: IotDeviceApi.DeviceMessageSendReq) { return requestClient.post('/iot/device/message/send', params); } + +/** 绑定子设备到网关设备 */ +export function bindDeviceGateway(gatewayId: number, subIds: number[]) { + return requestClient.put('/iot/device/bind-gateway', { + gatewayId, + subIds, + }); +} + +/** 解绑子设备与网关设备 */ +export function unbindDeviceGateway(gatewayId: number, subIds: number[]) { + return requestClient.put('/iot/device/unbind-gateway', { + gatewayId, + subIds, + }); +} + +/** 获取网关设备的子设备列表 */ +export function getSubDeviceList(gatewayId: number) { + return requestClient.get( + '/iot/device/sub-device-list', + { params: { gatewayId } }, + ); +} + +/** 获取未绑定的子设备分页 */ +export function getUnboundSubDevicePage(params: PageParam) { + return requestClient.get>( + '/iot/device/unbound-sub-device-page', + { params }, + ); +} diff --git a/apps/web-antd/src/api/iot/product/product/index.ts b/apps/web-antd/src/api/iot/product/product/index.ts index 2833b1e5d..335819dd3 100644 --- a/apps/web-antd/src/api/iot/product/product/index.ts +++ b/apps/web-antd/src/api/iot/product/product/index.ts @@ -8,6 +8,7 @@ export namespace IotProductApi { id?: number; // 产品编号 name: string; // 产品名称 productKey?: string; // 产品标识 + productSecret?: string; // 产品密钥 protocolId?: number; // 协议编号 protocolType?: number; // 接入协议类型 categoryId?: number; // 产品所属品类标识符 @@ -17,11 +18,11 @@ export namespace IotProductApi { description?: string; // 产品描述 status?: number; // 产品状态 deviceType?: number; // 设备类型 - locationType?: number; // 定位类型 netType?: number; // 联网方式 codecType?: string; // 数据格式(编解码器类型) dataFormat?: number; // 数据格式 validateType?: number; // 认证方式 + registerEnabled?: boolean; // 是否开启动态注册 deviceCount?: number; // 设备数量 createTime?: Date; // 创建时间 } @@ -68,8 +69,13 @@ export function updateProductStatus(id: number, status: number) { } /** 查询产品(精简)列表 */ -export function getSimpleProductList() { - return requestClient.get('/iot/product/simple-list'); +export function getSimpleProductList(deviceType?: number) { + return requestClient.get( + '/iot/product/simple-list', + { + params: { deviceType }, + }, + ); } /** 根据 ProductKey 获取产品信息 */ diff --git a/apps/web-antd/src/components/map/index.ts b/apps/web-antd/src/components/map/index.ts new file mode 100644 index 000000000..c8b84da5a --- /dev/null +++ b/apps/web-antd/src/components/map/index.ts @@ -0,0 +1,3 @@ +export { default as MapDialog } from './src/map-dialog.vue'; + +export { loadBaiduMapSdk } from './src/utils'; diff --git a/apps/web-antd/src/components/map/src/map-dialog.vue b/apps/web-antd/src/components/map/src/map-dialog.vue new file mode 100644 index 000000000..7b8c0bd87 --- /dev/null +++ b/apps/web-antd/src/components/map/src/map-dialog.vue @@ -0,0 +1,287 @@ + + + + diff --git a/apps/web-antd/src/components/map/src/utils.ts b/apps/web-antd/src/components/map/src/utils.ts new file mode 100644 index 000000000..6fc66fa91 --- /dev/null +++ b/apps/web-antd/src/components/map/src/utils.ts @@ -0,0 +1,62 @@ +/** + * 百度地图 SDK 加载工具 + */ + +// 扩展 Window 接口以包含百度地图 GL API +declare global { + interface Window { + BMapGL: any; + } +} + +// 全局回调名称 +const CALLBACK_NAME = '__BAIDU_MAP_LOAD_CALLBACK__'; + +// SDK 加载状态 +let loadPromise: null | Promise = null; + +/** + * 加载百度地图 GL SDK + * @param timeout 超时时间(毫秒),默认 10000 + * @returns Promise + */ +export const loadBaiduMapSdk = (timeout = 10_000): Promise => { + // 已加载完成 + if (window.BMapGL) { + return Promise.resolve(); + } + + // 正在加载中,返回同一个 Promise + if (loadPromise) { + return loadPromise; + } + + loadPromise = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + loadPromise = null; + reject(new Error('百度地图 SDK 加载超时')); + }, timeout); + + // 全局回调 + (window as any)[CALLBACK_NAME] = () => { + clearTimeout(timeoutId); + delete (window as any)[CALLBACK_NAME]; + resolve(); + }; + + // 创建 script 标签 + const script = document.createElement('script'); + script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${ + import.meta.env.VITE_BAIDU_MAP_KEY + }&callback=${CALLBACK_NAME}`; + script.onerror = () => { + clearTimeout(timeoutId); + loadPromise = null; + delete (window as any)[CALLBACK_NAME]; + reject(new Error('百度地图 SDK 加载失败')); + }; + document.body.append(script); + }); + + return loadPromise; +}; diff --git a/apps/web-antd/src/views/_core/profile/password-setting.vue b/apps/web-antd/src/views/_core/profile/password-setting.vue index b246bc37e..e5609c0b6 100644 --- a/apps/web-antd/src/views/_core/profile/password-setting.vue +++ b/apps/web-antd/src/views/_core/profile/password-setting.vue @@ -1,14 +1,12 @@