Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into feature/iot

This commit is contained in:
YunaiV
2026-02-13 15:40:15 +08:00
26 changed files with 1157 additions and 42 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "yudao-ui-admin-vue3",
"version": "2025.12-snapshot",
"version": "2026.01-snapshot",
"description": "基于vue3、vite4、element-plus、typesScript",
"author": "xingyu",
"private": false,

View File

@@ -21,7 +21,6 @@ export interface DeviceVO {
mqttClientId: string // MQTT 客户端 ID
mqttUsername: string // MQTT 用户名
mqttPassword: string // MQTT 密码
authType: string // 认证类型
latitude?: number // 设备位置的纬度
longitude?: number // 设备位置的经度
areaId: number // 地区编码
@@ -158,5 +157,28 @@ export const DeviceApi = {
// 发送设备消息
sendDeviceMessage: async (params: IotDeviceMessageSendReqVO) => {
return await request.post({ url: `/iot/device/message/send`, data: params })
},
// 绑定子设备到网关
bindDeviceGateway: async (data: { subIds: number[]; gatewayId: number }) => {
return await request.put({ url: `/iot/device/bind-gateway`, data })
},
// 解绑子设备与网关
unbindDeviceGateway: async (data: { subIds: number[]; gatewayId: number }) => {
return await request.put({ url: `/iot/device/unbind-gateway`, data })
},
// 获取网关的子设备列表
getSubDeviceList: async (gatewayId: number) => {
return await request.get<DeviceVO[]>({
url: `/iot/device/sub-device-list`,
params: { gatewayId }
})
},
// 获取未绑定网关的子设备分页
getUnboundSubDevicePage: async (params: any) => {
return await request.get({ url: `/iot/device/unbound-sub-device-page`, params })
}
}

View File

@@ -5,6 +5,8 @@ export interface ProductVO {
id: number // 产品编号
name: string // 产品名称
productKey: string // 产品标识
productSecret?: string // 产品密钥
registerEnabled?: boolean // 动态注册
protocolId: number // 协议编号
categoryId: number // 产品所属品类标识符
categoryName?: string // 产品所属品类名称
@@ -68,8 +70,8 @@ export const ProductApi = {
},
// 查询产品(精简)列表
getSimpleProductList() {
return request.get({ url: '/iot/product/simple-list' })
getSimpleProductList(deviceType?: number) {
return request.get({ url: '/iot/product/simple-list', params: { deviceType } })
},
// 根据 ProductKey 获取产品信息

View File

@@ -0,0 +1,138 @@
<!-- 省市区选择器 (Element Plus 版本 - Vue3) -->
<template>
<el-cascader
v-model="selectedValue"
class="w-full"
:options="areaTree"
:props="cascaderProps"
:disabled="disabled"
:placeholder="placeholder"
:clearable="clearable"
:show-all-levels="showAllLevels"
:separator="separator"
:loading="loading"
@change="handleChange"
/>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue'
import { getAreaTree } from '@/api/system/area'
// TODO @puhui999这里 handleTree 貌似没用到
import { handleTree } from '@/utils/tree'
defineOptions({ name: 'AreaSelect' })
interface AreaVO {
id: number
name: string
code: string
parentId?: number
sort?: number
status?: number
children?: AreaVO[]
}
interface Props {
modelValue?: number[] | string[]
level?: 1 | 2 | 3 // 1-省 2-市 3-区 TODO @puhui999这里是不是放到枚举类里
disabled?: boolean
placeholder?: string
clearable?: boolean
showAllLevels?: boolean
separator?: string
formCreateInject?: any
}
const props = withDefaults(defineProps<Props>(), {
modelValue: undefined,
level: 3, // TODO @puhui999枚举类
disabled: false,
placeholder: '请选择省市区',
clearable: true,
showAllLevels: true,
separator: '/'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number[] | string[] | undefined): void
}>()
const cascaderProps = {
label: 'name',
value: 'id',
children: 'children',
checkStrictly: true, // 允许选择任意级别
emitPath: true // 返回完整路径
} // Element Plus Cascader 的 props 配置
const areaTree = ref<AreaVO[]>([]) // 地区树形数据
const selectedValue = ref<number[] | undefined>() // 当前选中值
const loading = ref(false) // 加载状态
/** 加载地区树形数据 */
async function loadAreaTree(): Promise<void> {
try {
loading.value = true
const data = await getAreaTree()
// 根据 level 限制层级
areaTree.value = filterTreeByLevel(data || [], props.level)
} catch (error) {
console.warn('[AreaSelect] 加载地区数据失败:', error)
areaTree.value = []
} finally {
loading.value = false
}
}
/** 根据层级过滤树形数据 */
function filterTreeByLevel(tree: AreaVO[], maxLevel: number): AreaVO[] {
if (maxLevel <= 0) {
return []
}
return tree.map((node) => {
const newNode = { ...node }
// 如果当前是最后一层,移除 children
if (maxLevel === 1) {
delete newNode.children
} else if (node.children && node.children.length > 0) {
// 递归处理子节点
newNode.children = filterTreeByLevel(node.children, maxLevel - 1)
}
return newNode
})
}
/** 处理选中值变化 */
function handleChange(value: number[] | undefined): void {
if (value === undefined || value === null) {
emit('update:modelValue', undefined)
return
}
emit('update:modelValue', value)
}
/** 同步 modelValue 到内部选中值 */
function syncSelectedValue(): void {
const newValue = props.modelValue
if (newValue === undefined || newValue === null) {
selectedValue.value = undefined
return
}
// 确保是数组格式
if (Array.isArray(newValue)) {
selectedValue.value = newValue as number[]
} else {
selectedValue.value = [newValue as number]
}
}
/** 监听 modelValue 变化 */
watch(() => props.modelValue, syncSelectedValue, { immediate: true })
/** 组件挂载时加载数据 */
onMounted(async () => {
await loadAreaTree()
})
</script>

View File

@@ -0,0 +1,196 @@
<!-- 部门选择器 - 树形结构显示 -->
<template>
<el-tree-select
v-model="selectedValue"
class="w-1/1"
:data="deptTree"
:props="treeProps"
:multiple="multiple"
:disabled="disabled"
:placeholder="placeholder || '请选择部门'"
:check-strictly="true"
:filterable="true"
:filter-node-method="filterNode"
:clearable="true"
:render-after-expand="false"
node-key="id"
@change="handleChange"
/>
</template>
<script lang="ts" setup>
import { handleTree } from '@/utils/tree'
import { getSimpleDeptList, type DeptVO } from '@/api/system/dept'
import { useUserStoreWithOut } from '@/store/modules/user'
defineOptions({ name: 'DeptSelect' })
// 接受父组件参数
interface Props {
modelValue?: number | string | number[] | string[]
multiple?: boolean
returnType?: 'id' | 'name'
defaultCurrentDept?: boolean
disabled?: boolean
placeholder?: string
formCreateInject?: any
}
const props = withDefaults(defineProps<Props>(), {
multiple: false,
returnType: 'id',
defaultCurrentDept: false,
disabled: false,
placeholder: ''
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number | string | number[] | string[] | undefined): void
}>()
// 树形选择器配置
const treeProps = {
label: 'name',
value: 'id',
children: 'children'
}
// 部门树形数据
const deptTree = ref<any[]>([])
// 原始部门列表(用于 returnType='name' 时查找名称)
const deptList = ref<DeptVO[]>([])
// 当前选中值
const selectedValue = ref<number | string | number[] | string[] | undefined>()
// 加载部门树形数据
const loadDeptTree = async () => {
try {
const data = await getSimpleDeptList()
deptList.value = data
deptTree.value = handleTree(data)
} catch (error) {
console.warn('加载部门数据失败:', error)
deptTree.value = []
}
}
// 根据 ID 获取部门名称
const getDeptNameById = (id: number): string | undefined => {
const dept = deptList.value.find((item) => item.id === id)
return dept?.name
}
// 根据名称获取部门 ID
const getDeptIdByName = (name: string): number | undefined => {
const dept = deptList.value.find((item) => item.name === name)
return dept?.id
}
// 处理选中值变化
const handleChange = (value: number | number[] | undefined) => {
if (value === undefined || value === null) {
emit('update:modelValue', props.multiple ? [] : undefined)
return
}
// 根据 returnType 决定返回值类型
if (props.returnType === 'name') {
if (props.multiple && Array.isArray(value)) {
const names = value.map((id) => getDeptNameById(id)).filter(Boolean) as string[]
emit('update:modelValue', names)
} else if (!props.multiple && typeof value === 'number') {
const name = getDeptNameById(value)
emit('update:modelValue', name)
}
} else {
emit('update:modelValue', value)
}
}
// 树节点过滤方法(支持搜索过滤)
const filterNode = (value: string, data: any) => {
if (!value) return true
return data.name.includes(value)
}
// 监听 modelValue 变化,同步到内部选中值
watch(
() => props.modelValue,
(newValue) => {
if (newValue === undefined || newValue === null) {
selectedValue.value = props.multiple ? [] : undefined
return
}
// 如果 returnType 是 'name',需要将名称转换为 ID 用于树选择器显示
if (props.returnType === 'name') {
if (props.multiple && Array.isArray(newValue)) {
const ids = (newValue as string[])
.map((name) => getDeptIdByName(name))
.filter(Boolean) as number[]
selectedValue.value = ids
} else if (!props.multiple && typeof newValue === 'string') {
const id = getDeptIdByName(newValue)
selectedValue.value = id
}
} else {
selectedValue.value = newValue as number | number[]
}
},
{ immediate: true }
)
// 检查是否有有效的预设值
const hasValidPresetValue = (): boolean => {
const value = props.modelValue
if (value === undefined || value === null || value === '') {
return false
}
if (Array.isArray(value)) {
return value.length > 0
}
return true
}
// 设置默认值(当前用户部门)
const setDefaultValue = () => {
console.log('[DeptSelect] setDefaultValue called, defaultCurrentDept:', props.defaultCurrentDept)
// 仅当 defaultCurrentDept 为 true 时处理
if (!props.defaultCurrentDept) {
console.log('[DeptSelect] defaultCurrentDept is false, skip')
return
}
// 检查是否已有预设值(预设值优先级高于默认当前部门)
if (hasValidPresetValue()) {
console.log('[DeptSelect] has preset value, skip:', props.modelValue)
return
}
// 获取当前用户的部门 ID
const userStore = useUserStoreWithOut()
const user = userStore.getUser
const deptId = user?.deptId
console.log('[DeptSelect] current user:', user, 'deptId:', deptId)
// 处理 deptId 为空或 0 的边界情况
if (!deptId || deptId === 0) {
console.log('[DeptSelect] deptId is invalid, skip')
return
}
// 根据多选模式决定默认值格式
const defaultValue = props.multiple ? [deptId] : deptId
console.log('[DeptSelect] setting default value:', defaultValue)
emit('update:modelValue', defaultValue)
}
// 组件挂载时加载数据并设置默认值
onMounted(async () => {
await loadDeptTree()
// 数据加载完成后设置默认值
setDefaultValue()
})
</script>

View File

@@ -0,0 +1,102 @@
<!-- 网页 iframe 组件 (Element Plus 版本 - Vue3) -->
<template>
<div class="iframe-component">
<!-- iframe 预览 -->
<div v-if="showPreview" class="iframe-preview">
<iframe
:src="displayUrl"
:width="width"
:height="height"
:frameborder="frameborder"
:allowfullscreen="allowfullscreen"
:loading="loading"
:sandbox="sandbox || undefined"
class="iframe-content"
></iframe>
</div>
<!-- URL 或无效 URL 提示 -->
<div v-else class="iframe-placeholder">
<el-empty description="请在右侧属性面板配置 URL 地址" />
</div>
</div>
</template>
<script lang="ts" setup>
// TODO @AI多余的变量需要删除
import { computed, ref, watch } from 'vue'
defineOptions({ name: 'IframeComponent' })
interface Props {
modelValue?: string
url?: string
height?: string
width?: string
frameborder?: string
allowfullscreen?: boolean
loading?: 'eager' | 'lazy'
sandbox?: string
formCreateInject?: any
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
url: '',
height: '500px',
width: '100%',
frameborder: '0',
allowfullscreen: true,
loading: 'lazy',
sandbox: ''
})
// TODO @puhui999这里貌似暂时没用到
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const displayUrl = computed(() => props.url || props.modelValue || '') // 显示的 URL优先使用 url prop其次使用 modelValue
const showPreview = computed(() => {
return displayUrl.value && isValidUrl(displayUrl.value)
}) // 是否显示预览
// TODO @puhui999看看全局是不是有可复用的方法
/** URL 验证 */
function isValidUrl(url: string): boolean {
if (!url || url.trim() === '') return false
try {
const urlObj = new URL(url)
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
} catch {
return false
}
}
</script>
<style scoped>
.iframe-component {
width: 100%;
}
.iframe-preview {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.iframe-content {
display: block;
border: none;
}
.iframe-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
background-color: #fafafa;
}
</style>

View File

@@ -2,6 +2,7 @@ import request from '@/config/axios'
import { isEmpty } from '@/utils/is'
import { ApiSelectProps } from '@/components/FormCreate/src/type'
import { jsonParse } from '@/utils'
import { useUserStoreWithOut } from '@/store/modules/user'
export const useApiSelect = (option: ApiSelectProps) => {
return defineComponent({
@@ -61,13 +62,53 @@ export const useApiSelect = (option: ApiSelectProps) => {
returnType: {
type: String,
default: 'id'
},
// 是否默认选中当前用户(仅 UserSelect 使用)
defaultCurrentUser: {
type: Boolean,
default: false
}
},
setup(props) {
setup(props, { emit }) {
const attrs = useAttrs()
const options = ref<any[]>([]) // 下拉数据
const loading = ref(false) // 是否正在从远程获取数据
const queryParam = ref<any>() // 当前输入的值
// 检查是否有有效的预设值
const hasValidPresetValue = (): boolean => {
const value = attrs.modelValue
if (value === undefined || value === null || value === '') {
return false
}
if (Array.isArray(value)) {
return value.length > 0
}
return true
}
// 设置默认当前用户(仅当 defaultCurrentUser 为 true 且无预设值时)
const setDefaultCurrentUser = () => {
// 仅当组件名为 UserSelect 且 defaultCurrentUser 为 true 时处理
if (option.name !== 'UserSelect' || !props.defaultCurrentUser) {
return
}
// 检查是否已有预设值(预设值优先级高于默认当前用户)
if (hasValidPresetValue()) {
return
}
// 获取当前用户 ID
const userStore = useUserStoreWithOut()
const user = userStore.getUser
const currentUserId = user?.id
if (currentUserId) {
// 根据多选/单选模式设置默认值
const defaultValue = props.multiple ? [currentUserId] : currentUserId
emit('update:modelValue', defaultValue)
}
}
const getOptions = async () => {
options.value = []
// 接口选择器
@@ -188,6 +229,8 @@ export const useApiSelect = (option: ApiSelectProps) => {
onMounted(async () => {
await getOptions()
// 设置默认当前用户(在数据加载完成后)
setDefaultCurrentUser()
})
const buildSelect = () => {

View File

@@ -4,6 +4,8 @@ import { useUploadImgsRule } from './useUploadImgsRule'
import { useDictSelectRule } from './useDictSelectRule'
import { useEditorRule } from './useEditorRule'
import { useSelectRule } from './useSelectRule'
import { useIframeRule } from './useIframeRule'
import { useAreaSelectRule } from './useAreaSelectRule'
export {
useUploadFileRule,
@@ -11,5 +13,7 @@ export {
useUploadImgsRule,
useDictSelectRule,
useEditorRule,
useSelectRule
useSelectRule,
useIframeRule,
useAreaSelectRule
}

View File

@@ -0,0 +1,73 @@
import { generateUUID } from '@/utils'
import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
/**
* 省市区选择器规则
*/
export const useAreaSelectRule = () => {
const label = '省市区选择器'
const name = 'AreaSelect'
return {
icon: 'icon-location',
label,
name,
rule() {
return {
type: name,
field: generateUUID(),
title: label,
info: '',
$required: false
}
},
props(_, { t }) {
return localeProps(t, name + '.props', [
makeRequiredRule(),
{
type: 'select',
field: 'level',
title: '选择层级',
value: 3,
options: [
{ label: '省', value: 1 },
{ label: '省/市', value: 2 },
{ label: '省/市/区', value: 3 }
],
info: '限制可选择的地区层级'
},
{
type: 'input',
field: 'placeholder',
title: '占位符',
value: '请选择省市区'
},
{
type: 'switch',
field: 'clearable',
title: '是否可清空',
value: true
},
{
type: 'switch',
field: 'showAllLevels',
title: '显示完整路径',
value: true,
info: '输入框中是否显示选中值的完整路径'
},
{
type: 'input',
field: 'separator',
title: '分隔符',
value: '/',
info: '选项分隔符'
},
{
type: 'switch',
field: 'disabled',
title: '是否禁用',
value: false
}
])
}
}
}

View File

@@ -0,0 +1,73 @@
import { generateUUID } from '@/utils'
import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
/**
* iframe 组件规则
*/
export const useIframeRule = () => {
const label = '网页 iframe'
const name = 'IframeComponent'
return {
icon: 'icon-link',
label,
name,
rule() {
return {
type: name,
field: generateUUID(),
title: label,
info: '',
$required: false
}
},
props(_, { t }) {
return localeProps(t, name + '.props', [
makeRequiredRule(),
{
type: 'input',
field: 'url',
title: 'URL 地址',
value: '',
info: '请输入完整的 HTTP 或 HTTPS 地址'
},
{
type: 'input',
field: 'height',
title: 'iframe 高度',
value: '500px',
info: '支持 px、%、vh 等单位'
},
{
type: 'input',
field: 'width',
title: 'iframe 宽度',
value: '100%',
info: '支持 px、%、vw 等单位'
},
{
type: 'select',
field: 'loading',
title: '加载方式',
value: 'lazy',
options: [
{ label: '懒加载', value: 'lazy' },
{ label: '立即加载', value: 'eager' }
]
},
{
type: 'switch',
field: 'allowfullscreen',
title: '允许全屏',
value: true
},
{
type: 'input',
field: 'sandbox',
title: 'sandbox 属性',
value: '',
info: '安全沙箱限制allow-scripts allow-same-origin'
}
])
}
}
}

View File

@@ -19,13 +19,24 @@ export const useSelectRule = (option: SelectRuleOption) => {
name,
event: option.event,
rule() {
return {
// 构建基础规则
const baseRule: any = {
type: name,
field: generateUUID(),
title: label,
info: '',
$required: false
}
// 将自定义 props 的默认值添加到 rule 的 props 中
if (option.props && option.props.length > 0) {
baseRule.props = {}
option.props.forEach((prop: any) => {
if (prop.field && prop.value !== undefined) {
baseRule.props[prop.field] = prop.value
}
})
}
return baseRule
},
props(_, { t }) {
if (!option.props) {

View File

@@ -4,7 +4,9 @@ import {
useSelectRule,
useUploadFileRule,
useUploadImgRule,
useUploadImgsRule
useUploadImgsRule,
useIframeRule,
useAreaSelectRule
} from './config'
import { Ref } from 'vue'
import { Menu } from '@/components/FormCreate/src/type'
@@ -36,7 +38,9 @@ export const useFormCreateDesigner = async (designer: Ref) => {
designer.value?.removeMenuItem('upload')
// 移除自带的富文本组件规则,使用 editorRule 替代
designer.value?.removeMenuItem('fcEditor')
const components = [editorRule, uploadFileRule, uploadImgRule, uploadImgsRule]
const iframeRule = useIframeRule()
const areaSelectRule = useAreaSelectRule()
const components = [editorRule, uploadFileRule, uploadImgRule, uploadImgsRule, iframeRule, areaSelectRule]
components.forEach((component) => {
// 插入组件规则
designer.value?.addComponent(component)
@@ -52,7 +56,15 @@ export const useFormCreateDesigner = async (designer: Ref) => {
const userSelectRule = useSelectRule({
name: 'UserSelect',
label: '用户选择器',
icon: 'icon-user-o'
icon: 'icon-user-o',
props: [
{
type: 'switch',
field: 'defaultCurrentUser',
title: '默认选中当前用户',
value: false
}
]
})
const deptSelectRule = useSelectRule({
name: 'DeptSelect',
@@ -68,6 +80,12 @@ export const useFormCreateDesigner = async (designer: Ref) => {
{ label: '部门编号', value: 'id' },
{ label: '部门名称', value: 'name' }
]
},
{
type: 'switch',
field: 'defaultCurrentDept',
title: '默认选中当前部门',
value: false
}
]
})

View File

@@ -85,7 +85,7 @@ service.interceptors.request.use(
}
}
// 是否 API 加密
if ((config!.headers || {}).isEncrypt) {
if ((config!.headers || {}).isEncrypt && !(config!.headers || {}).isEncrypted) {
try {
// 加密请求数据
if (config.data) {
@@ -169,6 +169,9 @@ service.interceptors.response.use(
cb()
})
requestList = []
if ((config!.headers || {}).isEncrypt){
(config!.headers || {}).isEncrypted = true
}
return service(config)
} catch (e) {
// 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。

View File

@@ -68,6 +68,9 @@ import { UploadFile, UploadImg, UploadImgs } from '@/components/UploadFile'
import { useApiSelect } from '@/components/FormCreate'
import { Editor } from '@/components/Editor'
import DictSelect from '@/components/FormCreate/src/components/DictSelect.vue'
import DeptSelect from '@/components/FormCreate/src/components/DeptSelect.vue'
import IframeComponent from '@/components/FormCreate/src/components/IframeComponent.vue'
import AreaSelect from '@/components/FormCreate/src/components/AreaSelect.vue'
const UserSelect = useApiSelect({
name: 'UserSelect',
@@ -75,12 +78,6 @@ const UserSelect = useApiSelect({
valueField: 'id',
url: '/system/user/simple-list'
})
const DeptSelect = useApiSelect({
name: 'DeptSelect',
labelField: 'name',
valueField: 'id',
url: '/system/dept/simple-list'
})
const ApiSelect = useApiSelect({
name: 'ApiSelect'
})
@@ -119,6 +116,8 @@ const components = [
DeptSelect,
ApiSelect,
Editor,
IframeComponent,
AreaSelect,
ElCollapse,
ElCollapseItem,
ElCard

View File

@@ -39,6 +39,7 @@
</el-col>
<Verify
ref="verify"
v-if="resetPasswordData.captchaEnable === 'true'"
:captchaType="captchaType"
:imgSize="{ width: '400px', height: '200px' }"
mode="pop"

View File

@@ -18,7 +18,7 @@
>
转化为客户
</el-button>
<el-button v-else disabled type="success">已转化客户</el-button>
<el-button v-if="clue.transformStatus" disabled type="success">已转化客户</el-button>
</ClueDetailsHeader>
<el-col>
<el-tabs>

View File

@@ -30,20 +30,6 @@
:disabled="formType === 'update'"
/>
</el-form-item>
<el-form-item
v-if="formData.deviceType === DeviceTypeEnum.GATEWAY_SUB"
label="网关设备"
prop="gatewayId"
>
<el-select v-model="formData.gatewayId" placeholder="子设备可选择父设备" clearable>
<el-option
v-for="gateway in gatewayDevices"
:key="gateway.id"
:label="gateway.nickname || gateway.deviceName"
:value="gateway.id"
/>
</el-select>
</el-form-item>
<el-collapse>
<el-collapse-item title="更多配置">
@@ -114,7 +100,6 @@ const formData = ref({
deviceName: undefined,
nickname: undefined,
picUrl: undefined,
gatewayId: undefined,
deviceType: undefined as number | undefined,
serialNumber: undefined,
longitude: undefined as number | string | undefined,
@@ -222,7 +207,6 @@ const formRules = reactive({
})
const formRef = ref() // 表单 Ref
const products = ref<ProductVO[]>([]) // 产品列表
const gatewayDevices = ref<DeviceVO[]>([]) // 网关设备列表
const deviceGroups = ref<any[]>([])
/** 打开弹窗 */
@@ -242,8 +226,6 @@ const open = async (type: string, id?: number) => {
}
}
// 加载网关设备列表
gatewayDevices.value = await DeviceApi.getSimpleDeviceList(DeviceTypeEnum.GATEWAY)
// 加载产品列表
products.value = await ProductApi.getSimpleProductList()
// 加载设备分组列表
@@ -283,7 +265,6 @@ const resetForm = () => {
deviceName: undefined,
nickname: undefined,
picUrl: undefined,
gatewayId: undefined,
deviceType: undefined,
serialNumber: undefined,
longitude: undefined,

View File

@@ -0,0 +1,264 @@
<!-- 子设备管理 -->
<template>
<ContentWrap>
<!-- 操作按钮 -->
<div class="mb-4">
<el-button type="primary" plain @click="openBindDialog" v-hasPermi="['iot:device:update']">
<Icon icon="ep:plus" class="mr-5px" /> 添加子设备
</el-button>
<el-button
type="danger"
plain
@click="handleUnbindBatch"
:disabled="selectedIds.length === 0"
v-hasPermi="['iot:device:update']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量解绑
</el-button>
</div>
<!-- 子设备列表 -->
<el-table
v-loading="loading"
:data="subDeviceList"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="DeviceName" align="center" prop="deviceName">
<template #default="{ row }">
<el-link type="primary" @click="openDeviceDetail(row.id)">{{ row.deviceName }}</el-link>
</template>
</el-table-column>
<el-table-column label="备注名称" align="center" prop="nickname" />
<el-table-column label="产品名称" align="center" prop="productName" />
<el-table-column label="设备状态" align="center" prop="state">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="row.state" />
</template>
</el-table-column>
<el-table-column
label="最后上线时间"
align="center"
prop="onlineTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" width="120px">
<template #default="{ row }">
<el-button link type="primary" @click="openDeviceDetail(row.id)"> 查看 </el-button>
<el-button
link
type="danger"
@click="handleUnbind(row.id)"
v-hasPermi="['iot:device:update']"
>
解绑
</el-button>
</template>
</el-table-column>
</el-table>
</ContentWrap>
<!-- 添加子设备弹窗 -->
<Dialog title="添加子设备" v-model="bindDialogVisible" width="900px">
<ContentWrap>
<!-- 搜索区域 -->
<el-form :model="bindQueryParams" ref="bindQueryFormRef" :inline="true" class="-mb-15px">
<el-form-item label="产品" prop="productId">
<ProductSelect
v-model="bindQueryParams.productId"
:device-type="DeviceTypeEnum.GATEWAY_SUB"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="bindQueryParams.deviceName"
placeholder="请输入设备名称"
clearable
class="!w-200px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getBindableDevicePage">
<Icon icon="ep:search" class="mr-5px" /> 搜索
</el-button>
<el-button @click="resetBindQuery">
<Icon icon="ep:refresh" class="mr-5px" /> 重置
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<!-- 分页表格 -->
<el-table
ref="bindTableRef"
v-loading="bindFormLoading"
:data="bindableDevices"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleBindSelectionChange"
max-height="400px"
>
<el-table-column type="selection" width="55" />
<el-table-column label="DeviceName" align="center" prop="deviceName" />
<el-table-column label="备注名称" align="center" prop="nickname" />
<el-table-column label="产品名称" align="center" prop="productName" />
<el-table-column label="设备状态" align="center" prop="state">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="row.state" />
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<Pagination
v-model:page="bindQueryParams.pageNo"
v-model:limit="bindQueryParams.pageSize"
:total="bindTotal"
@pagination="getBindableDevicePage"
/>
</ContentWrap>
<template #footer>
<el-button type="primary" @click="handleBindSubmit" :loading="bindFormLoading">
确定已选 {{ bindSelectedIds.length }}
</el-button>
<el-button @click="bindDialogVisible = false">取消</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { DeviceTypeEnum } from '@/api/iot/product/product'
import { DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import ProductSelect from '@/views/iot/product/product/components/ProductSelect.vue'
const props = defineProps<{
gatewayId: number
}>()
const message = useMessage()
const { push } = useRouter()
const loading = ref(false) // 列表加载状态
const subDeviceList = ref<DeviceVO[]>([]) // 子设备列表
const selectedIds = ref<number[]>([]) // 选中的设备ID
const bindDialogVisible = ref(false) // 绑定弹窗可见性
const bindFormLoading = ref(false) // 绑定弹窗加载状态
const bindTableRef = ref()
const bindQueryFormRef = ref()
const bindableDevices = ref<DeviceVO[]>([]) // 可绑定设备列表
const bindSelectedIds = ref<number[]>([]) // 绑定选中的设备ID
const bindTotal = ref(0) // 可绑定设备总数
const bindQueryParams = reactive({
pageNo: 1,
pageSize: 10,
productId: undefined as number | undefined,
deviceName: ''
})
/** 获取子设备列表 */
const getSubDeviceList = async () => {
loading.value = true
try {
subDeviceList.value = await DeviceApi.getSubDeviceList(props.gatewayId)
} finally {
loading.value = false
}
}
/** 打开设备详情 */
const openDeviceDetail = (id: number) => {
push({ name: 'IoTDeviceDetail', params: { id } })
}
/** 多选框选中数据 */
const handleSelectionChange = (selection: DeviceVO[]) => {
selectedIds.value = selection.map((item) => item.id)
}
/** 打开绑定弹窗 */
const openBindDialog = async () => {
bindSelectedIds.value = []
bindDialogVisible.value = true
await getBindableDevicePage()
}
/** 获取可绑定设备分页 */
const getBindableDevicePage = async () => {
bindFormLoading.value = true
try {
const result = await DeviceApi.getUnboundSubDevicePage(bindQueryParams)
bindableDevices.value = result.list
bindTotal.value = result.total
} finally {
bindFormLoading.value = false
}
}
/** 重置绑定弹窗搜索条件 */
const resetBindQuery = () => {
bindQueryParams.pageNo = 1
bindQueryParams.productId = undefined
bindQueryParams.deviceName = ''
getBindableDevicePage()
}
/** 绑定弹窗多选框选中数据 */
const handleBindSelectionChange = (selection: DeviceVO[]) => {
bindSelectedIds.value = selection.map((item) => item.id)
}
/** 提交绑定 */
const handleBindSubmit = async () => {
if (bindSelectedIds.value.length === 0) {
message.warning('请选择要绑定的子设备')
return
}
bindFormLoading.value = true
try {
await DeviceApi.bindDeviceGateway({
subIds: bindSelectedIds.value,
gatewayId: props.gatewayId
})
message.success('绑定成功')
bindDialogVisible.value = false
await getSubDeviceList()
} finally {
bindFormLoading.value = false
}
}
/** 解绑单个设备 */
const handleUnbind = async (id: number) => {
try {
await message.confirm('确定要解绑该子设备吗?')
await DeviceApi.unbindDeviceGateway({ subIds: [id], gatewayId: props.gatewayId })
message.success('解绑成功')
await getSubDeviceList()
} catch {}
}
/** 批量解绑 */
const handleUnbindBatch = async () => {
try {
await message.confirm(`确定要解绑选中的 ${selectedIds.value.length} 个子设备吗?`)
await DeviceApi.unbindDeviceGateway({ subIds: selectedIds.value, gatewayId: props.gatewayId })
message.success('批量解绑成功')
selectedIds.value = []
await getSubDeviceList()
} catch {}
}
/** 初始化 */
onMounted(async () => {
await getSubDeviceList()
})
</script>

View File

@@ -17,7 +17,13 @@
:thing-model-list="thingModelList"
/>
</el-tab-pane>
<el-tab-pane label="子设备管理" v-if="product.deviceType === DeviceTypeEnum.GATEWAY" />
<el-tab-pane
label="子设备管理"
name="subDevice"
v-if="product.deviceType === DeviceTypeEnum.GATEWAY"
>
<DeviceDetailsSubDevice v-if="activeTab === 'subDevice'" :gateway-id="device.id" />
</el-tab-pane>
<el-tab-pane label="设备消息" name="log">
<DeviceDetailsMessage v-if="activeTab === 'log'" :device-id="device.id" />
</el-tab-pane>
@@ -50,6 +56,7 @@ import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue'
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
import DeviceDetailConfig from './DeviceDetailConfig.vue'
import DeviceDetailsSubDevice from './DeviceDetailsSubDevice.vue'
defineOptions({ name: 'IoTDeviceDetail' })

View File

@@ -75,6 +75,20 @@
</el-form-item>
<el-collapse>
<el-collapse-item title="更多配置">
<el-form-item label="动态注册" prop="registerEnabled">
<template #label>
<el-tooltip
content="设备动态注册无需一一烧录设备证书DeviceSecret每台设备烧录相同的产品证书即 ProductKey 和 ProductSecret ,云端鉴权通过后下发设备证书,您可以根据需要开启或关闭动态注册,保障安全性。"
placement="top"
>
<span>
动态注册
<Icon icon="ep:question-filled" class="ml-2px" />
</span>
</el-tooltip>
</template>
<el-switch v-model="formData.registerEnabled" />
</el-form-item>
<el-form-item label="产品图标" prop="icon">
<UploadImg v-model="formData.icon" :height="'80px'" :width="'80px'" />
</el-form-item>
@@ -120,7 +134,8 @@ const formData = ref({
description: undefined,
deviceType: undefined,
netType: undefined,
codecType: CodecTypeEnum.ALINK
codecType: CodecTypeEnum.ALINK,
registerEnabled: false
})
const formRules = reactive({
productKey: [{ required: true, message: 'ProductKey 不能为空', trigger: 'blur' }],
@@ -194,7 +209,8 @@ const resetForm = () => {
description: undefined,
deviceType: undefined,
netType: undefined,
codecType: CodecTypeEnum.ALINK
codecType: CodecTypeEnum.ALINK,
registerEnabled: false
}
formRef.value?.resetFields()
}

View File

@@ -0,0 +1,65 @@
<!-- 产品下拉选择器组件 -->
<template>
<el-select
:model-value="modelValue"
@update:model-value="handleChange"
placeholder="请选择产品"
filterable
clearable
class="w-full"
:loading="loading"
>
<el-option
v-for="product in productList"
:key="product.id"
:label="product.name"
:value="product.id"
/>
</el-select>
</template>
<script setup lang="ts">
import { ProductApi } from '@/api/iot/product/product'
/** 产品下拉选择器组件 */
defineOptions({ name: 'ProductSelect' })
const props = defineProps<{
modelValue?: number
deviceType?: number // 设备类型过滤
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value?: number): void
(e: 'change', value?: number): void
}>()
const loading = ref(false) // 产品加载状态
const productList = ref<any[]>([]) // 产品列表
/**
* 处理选择变化事件
*
* @param value 选中的产品 ID
*/
const handleChange = (value?: number) => {
emit('update:modelValue', value)
emit('change', value)
}
/** 获取产品列表 */
const getProductList = async () => {
try {
loading.value = true
const res = await ProductApi.getSimpleProductList(props.deviceType)
productList.value = res || []
} finally {
loading.value = false
}
}
/** 组件挂载时获取产品列表 */
onMounted(() => {
getProductList()
})
</script>

View File

@@ -21,6 +21,28 @@
>
<dict-tag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
</el-descriptions-item>
<el-descriptions-item label="动态注册">
<el-tag :type="product.registerEnabled ? 'success' : 'info'">
{{ product.registerEnabled ? '已开启' : '已关闭' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="产品密钥">
<div class="flex items-center">
<span>{{ secretVisible ? product.productSecret : '******' }}</span>
<el-button link type="primary" class="ml-2" @click="secretVisible = !secretVisible">
<Icon :icon="secretVisible ? 'ep:hide' : 'ep:view'" />
</el-button>
<el-button
v-if="secretVisible && product.productSecret"
link
type="primary"
class="ml-1"
@click="copySecret"
>
<Icon icon="ep:document-copy" />
</el-button>
</div>
</el-descriptions-item>
<el-descriptions-item label="产品描述">{{ product.description }}</el-descriptions-item>
</el-descriptions>
</ContentWrap>
@@ -29,6 +51,19 @@
import { DICT_TYPE } from '@/utils/dict'
import { DeviceTypeEnum, ProductVO } from '@/api/iot/product/product'
import { formatDate } from '@/utils/formatTime'
import { useClipboard } from '@vueuse/core'
const { product } = defineProps<{ product: ProductVO }>()
const message = useMessage()
const secretVisible = ref(false)
const { copy } = useClipboard()
/** 复制产品密钥 */
const copySecret = async () => {
if (product.productSecret) {
await copy(product.productSecret)
message.success('复制成功')
}
}
</script>

View File

@@ -24,7 +24,41 @@ export const IotDeviceMessageMethodEnum = {
// ========== 设备状态 ==========
STATE_UPDATE: {
method: 'thing.state.update',
name: '设备状态更',
name: '设备状态更',
upstream: true
},
// ========== 拓扑管理 ==========
TOPO_ADD: {
method: 'thing.topo.add',
name: '添加拓扑关系',
upstream: true
},
TOPO_DELETE: {
method: 'thing.topo.delete',
name: '删除拓扑关系',
upstream: true
},
TOPO_GET: {
method: 'thing.topo.get',
name: '获取拓扑关系',
upstream: true
},
TOPO_CHANGE: {
method: 'thing.topo.change',
name: '拓扑关系变更通知',
upstream: false
},
// ========== 设备注册 ==========
DEVICE_REGISTER: {
method: 'thing.auth.register',
name: '设备动态注册',
upstream: true
},
SUB_DEVICE_REGISTER: {
method: 'thing.auth.register.sub',
name: '子设备动态注册',
upstream: true
},
@@ -39,6 +73,11 @@ export const IotDeviceMessageMethodEnum = {
name: '属性设置',
upstream: false
},
PROPERTY_PACK_POST: {
method: 'thing.event.property.pack.post',
name: '批量上报(属性 + 事件 + 子设备)',
upstream: true
},
// ========== 设备事件 ==========
EVENT_POST: {
@@ -59,6 +98,18 @@ export const IotDeviceMessageMethodEnum = {
method: 'thing.config.push',
name: '配置推送',
upstream: false
},
// ========== OTA 固件 ==========
OTA_UPGRADE: {
method: 'thing.ota.upgrade',
name: 'OTA 固件信息推送',
upstream: false
},
OTA_PROGRESS: {
method: 'thing.ota.progress',
name: 'OTA 升级进度上报',
upstream: true
}
}

View File

@@ -318,6 +318,7 @@ const props = defineProps({
const formData: Ref<Spu | undefined> = ref<Spu>() // 表单数据
const skuList = ref<Sku[]>([
{
name: '', // SKU 名称
price: 0, // 商品价格
marketPrice: 0, // 市场价
costPrice: 0, // 成本价
@@ -449,6 +450,7 @@ const generateTableData = (propertyList: any[]) => {
}
for (const item of buildSkuList) {
const row = {
name: '', // SKU 名称,提交时会自动使用 SPU 名称
properties: Array.isArray(item) ? item : [item], // 如果只有一个属性的话返回的是一个 property 对象
price: 0,
marketPrice: 0,
@@ -525,6 +527,7 @@ watch(
if (props.isBatch) {
skuList.value = [
{
name: '', // SKU 名称
price: 0,
marketPrice: 0,
costPrice: 0,

View File

@@ -173,6 +173,7 @@ const onChangeSpec = () => {
// 重置sku列表
formData.skus = [
{
name: '', // SKU 名称,提交时会自动使用 SPU 名称
price: 0,
marketPrice: 0,
costPrice: 0,

View File

@@ -62,6 +62,7 @@ import OtherForm from './OtherForm.vue'
import SkuForm from './SkuForm.vue'
import DeliveryForm from './DeliveryForm.vue'
import { convertToInteger, floatToFixed2, formatToFraction } from '@/utils'
import { isEmpty } from '@/utils/is'
defineOptions({ name: 'ProductSpuAdd' })
@@ -94,6 +95,7 @@ const formData = ref<ProductSpuApi.Spu>({
subCommissionType: false, // 分销类型
skus: [
{
name: '', // SKU 名称,提交时会自动使用 SPU 名称
price: 0, // 商品价格
marketPrice: 0, // 市场价
costPrice: 0, // 成本价
@@ -158,8 +160,13 @@ const submitForm = async () => {
await unref(otherRef)?.validate()
// 深拷贝一份, 这样最终 server 端不满足,不需要影响原始数据
const deepCopyFormData = cloneDeep(unref(formData.value)) as ProductSpuApi.Spu
// 校验商品名称不能为空(用于 SKU name
if (isEmpty(deepCopyFormData.name)) {
message.error('商品名称不能为空')
return
}
deepCopyFormData.skus!.forEach((item) => {
// 给sku name赋值
// 给sku name赋值(使用商品名称作为 SKU 名称)
item.name = deepCopyFormData.name
// sku相关价格元转分
item.price = convertToInteger(item.price)