mirror of
https://github.com/yudaocode/yudao-ui-admin-vue3.git
synced 2026-04-19 02:57:27 +00:00
Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into feature/iot
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 获取产品信息
|
||||
|
||||
138
src/components/FormCreate/src/components/AreaSelect.vue
Normal file
138
src/components/FormCreate/src/components/AreaSelect.vue
Normal 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>
|
||||
196
src/components/FormCreate/src/components/DeptSelect.vue
Normal file
196
src/components/FormCreate/src/components/DeptSelect.vue
Normal 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>
|
||||
102
src/components/FormCreate/src/components/IframeComponent.vue
Normal file
102
src/components/FormCreate/src/components/IframeComponent.vue
Normal 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>
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
73
src/components/FormCreate/src/config/useAreaSelectRule.ts
Normal file
73
src/components/FormCreate/src/config/useAreaSelectRule.ts
Normal 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
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/components/FormCreate/src/config/useIframeRule.ts
Normal file
73
src/components/FormCreate/src/config/useIframeRule.ts
Normal 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'
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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 触发异常。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
</el-col>
|
||||
<Verify
|
||||
ref="verify"
|
||||
v-if="resetPasswordData.captchaEnable === 'true'"
|
||||
:captchaType="captchaType"
|
||||
:imgSize="{ width: '400px', height: '200px' }"
|
||||
mode="pop"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
264
src/views/iot/device/device/detail/DeviceDetailsSubDevice.vue
Normal file
264
src/views/iot/device/device/detail/DeviceDetailsSubDevice.vue
Normal 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>
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
65
src/views/iot/product/product/components/ProductSelect.vue
Normal file
65
src/views/iot/product/product/components/ProductSelect.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -173,6 +173,7 @@ const onChangeSpec = () => {
|
||||
// 重置sku列表
|
||||
formData.skus = [
|
||||
{
|
||||
name: '', // SKU 名称,提交时会自动使用 SPU 名称
|
||||
price: 0,
|
||||
marketPrice: 0,
|
||||
costPrice: 0,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user