Commit be64163857f9003c7ce0a7dbe49f5205dfe3b8cd
Merge branch 'feat/device-ota-package' into 'main_dev'
feat: 设备编辑时新增ota升级包 See merge request yunteng/thingskit-front!1220
Showing
6 changed files
with
278 additions
and
2 deletions
| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | GetDeviceProfileInfosParams, |
| 7 | 7 | GetOtaPackagesParams, |
| 8 | 8 | OtaRecordDatum, |
| 9 | + QueryDeviceProfileOtaPackagesType, | |
| 9 | 10 | TBResponse, |
| 10 | 11 | UploadOtaPackagesParams, |
| 11 | 12 | } from './model'; |
| ... | ... | @@ -137,3 +138,18 @@ export const getDeviceProfileInfoById = (id: string) => { |
| 137 | 138 | { joinPrefix: false } |
| 138 | 139 | ); |
| 139 | 140 | }; |
| 141 | + | |
| 142 | +export const getDeviceProfileOtaPackages = (params: QueryDeviceProfileOtaPackagesType) => { | |
| 143 | + const { deviceProfileId, type, page, pageSize, textSearch } = params; | |
| 144 | + return defHttp.get<TBResponse<OtaRecordDatum>>( | |
| 145 | + { | |
| 146 | + url: `${Api.GET_OTA_PACKAGES}/${deviceProfileId}/${type}`, | |
| 147 | + params: { | |
| 148 | + page, | |
| 149 | + pageSize, | |
| 150 | + textSearch, | |
| 151 | + }, | |
| 152 | + }, | |
| 153 | + { joinPrefix: false } | |
| 154 | + ); | |
| 155 | +}; | ... | ... |
| 1 | +import { OTAPackageType } from '/@/enums/otaEnum'; | |
| 1 | 2 | import { ALG, CheckSumWay } from '/@/views/operation/ota/config/packageDetail.config'; |
| 3 | +import { OrderByEnum } from '/@/views/rule/designer/enum/form'; | |
| 2 | 4 | |
| 3 | 5 | export interface GetOtaPackagesParams { |
| 4 | 6 | pageSize: number; |
| ... | ... | @@ -105,3 +107,13 @@ export interface DeviceProfileRecord { |
| 105 | 107 | type: string; |
| 106 | 108 | transportType: string; |
| 107 | 109 | } |
| 110 | + | |
| 111 | +export interface QueryDeviceProfileOtaPackagesType { | |
| 112 | + deviceProfileId: string; | |
| 113 | + type: OTAPackageType; | |
| 114 | + page: number; | |
| 115 | + pageSize: number; | |
| 116 | + textSearch?: string; | |
| 117 | + sortProperty?: string; | |
| 118 | + sortOrder?: OrderByEnum; | |
| 119 | +} | ... | ... |
| 1 | +<script lang="ts"> | |
| 2 | + export type OptionsItem = { label: string; value: string; disabled?: boolean }; | |
| 3 | + export interface OnChangeHookParams { | |
| 4 | + options: Ref<OptionsItem[]>; | |
| 5 | + } | |
| 6 | +</script> | |
| 7 | + | |
| 8 | +<script lang="ts" setup> | |
| 9 | + import { ref, watchEffect, computed, unref, watch, Ref } from 'vue'; | |
| 10 | + import { Select } from 'ant-design-vue'; | |
| 11 | + import { isFunction } from '/@/utils/is'; | |
| 12 | + import { useRuleFormItem } from '/@/hooks/component/useFormItem'; | |
| 13 | + import { get, omit } from 'lodash-es'; | |
| 14 | + import { LoadingOutlined } from '@ant-design/icons-vue'; | |
| 15 | + import { useI18n } from '/@/hooks/web/useI18n'; | |
| 16 | + import { useDebounceFn } from '@vueuse/shared'; | |
| 17 | + | |
| 18 | + const emit = defineEmits(['options-change', 'change']); | |
| 19 | + const props = withDefaults( | |
| 20 | + defineProps<{ | |
| 21 | + value?: Recordable | number | string; | |
| 22 | + numberToString?: boolean; | |
| 23 | + api?: (arg?: Recordable) => Promise<Recordable>; | |
| 24 | + queryApi?: (value?: any) => Promise<Recordable>; | |
| 25 | + params?: Recordable | ((searchText?: string) => Recordable); | |
| 26 | + resultField?: string; | |
| 27 | + labelField?: string; | |
| 28 | + valueField?: string; | |
| 29 | + immediate?: boolean; | |
| 30 | + queryEmptyDataAgin?: boolean; | |
| 31 | + }>(), | |
| 32 | + { | |
| 33 | + resultField: '', | |
| 34 | + labelField: 'label', | |
| 35 | + valueField: 'value', | |
| 36 | + searchField: 'text', | |
| 37 | + immediate: true, | |
| 38 | + queryEmptyDataAgin: true, | |
| 39 | + } | |
| 40 | + ); | |
| 41 | + | |
| 42 | + const selectOption = ref<OptionsItem>(); | |
| 43 | + const options = ref<OptionsItem[]>([]); | |
| 44 | + const loading = ref(false); | |
| 45 | + const isFirstLoad = ref(true); | |
| 46 | + const emitData = ref<any[]>([]); | |
| 47 | + const { t } = useI18n(); | |
| 48 | + | |
| 49 | + // Embedded in the form, just use the hook binding to perform form verification | |
| 50 | + const [state] = useRuleFormItem(props, 'value', 'change', emitData); | |
| 51 | + | |
| 52 | + const getOptions = computed(() => { | |
| 53 | + const { labelField, valueField = 'value', numberToString } = props; | |
| 54 | + const _options = unref(options); | |
| 55 | + | |
| 56 | + if ( | |
| 57 | + unref(selectOption) && | |
| 58 | + !_options.find((item) => get(item, valueField) === get(unref(selectOption), valueField)) | |
| 59 | + ) { | |
| 60 | + _options.push(unref(selectOption)!); | |
| 61 | + } | |
| 62 | + return _options.reduce((prev, next: Recordable) => { | |
| 63 | + if (next) { | |
| 64 | + const value = get(next, valueField); | |
| 65 | + const label = get(next, labelField); | |
| 66 | + prev.push({ | |
| 67 | + ...omit(next, [labelField, valueField]), | |
| 68 | + label, | |
| 69 | + value: numberToString ? `${value}` : value, | |
| 70 | + }); | |
| 71 | + } | |
| 72 | + return prev; | |
| 73 | + }, [] as OptionsItem[]); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + watchEffect(() => { | |
| 77 | + props.immediate && fetch(); | |
| 78 | + }); | |
| 79 | + | |
| 80 | + watch( | |
| 81 | + () => props.params, | |
| 82 | + () => { | |
| 83 | + !unref(isFirstLoad) && fetch(); | |
| 84 | + }, | |
| 85 | + { deep: true } | |
| 86 | + ); | |
| 87 | + | |
| 88 | + watch( | |
| 89 | + () => props.value, | |
| 90 | + async (target) => { | |
| 91 | + if (target && props.queryApi && isFunction(props.queryApi)) { | |
| 92 | + if (unref(getOptions).find((item) => item.value === target)) return; | |
| 93 | + const detail = await props.queryApi(target); | |
| 94 | + if ( | |
| 95 | + unref(options).find( | |
| 96 | + (item) => get(item, props.valueField) === get(detail, props.valueField) | |
| 97 | + ) | |
| 98 | + ) | |
| 99 | + return; | |
| 100 | + | |
| 101 | + selectOption.value = detail as OptionsItem; | |
| 102 | + } | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + immediate: true, | |
| 106 | + } | |
| 107 | + ); | |
| 108 | + | |
| 109 | + async function fetch(searchText?: string) { | |
| 110 | + const api = props.api; | |
| 111 | + if (!api || !isFunction(api)) return; | |
| 112 | + options.value = []; | |
| 113 | + try { | |
| 114 | + loading.value = true; | |
| 115 | + const params = | |
| 116 | + props.params && isFunction(props.params) ? props.params(searchText) : props.params; | |
| 117 | + | |
| 118 | + const res = await api(params); | |
| 119 | + | |
| 120 | + if (Array.isArray(res)) { | |
| 121 | + options.value = res; | |
| 122 | + emitChange(); | |
| 123 | + return; | |
| 124 | + } | |
| 125 | + | |
| 126 | + if (props.resultField) { | |
| 127 | + options.value = get(res, props.resultField) || []; | |
| 128 | + } | |
| 129 | + emitChange(); | |
| 130 | + } catch (error) { | |
| 131 | + console.warn(error); | |
| 132 | + } finally { | |
| 133 | + loading.value = false; | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + async function handleFetch() { | |
| 138 | + const { immediate } = props; | |
| 139 | + if (!immediate && unref(isFirstLoad)) { | |
| 140 | + await fetch(); | |
| 141 | + isFirstLoad.value = false; | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + function emitChange() { | |
| 146 | + emit('options-change', unref(getOptions)); | |
| 147 | + } | |
| 148 | + | |
| 149 | + function handleChange(value: string, ...args) { | |
| 150 | + emitData.value = args; | |
| 151 | + if (!value && props.queryEmptyDataAgin) fetch(); | |
| 152 | + } | |
| 153 | + | |
| 154 | + const debounceSearchFunction = useDebounceFn(fetch, 300); | |
| 155 | +</script> | |
| 156 | + | |
| 157 | +<template> | |
| 158 | + <Select | |
| 159 | + v-bind="$attrs" | |
| 160 | + show-search | |
| 161 | + @dropdownVisibleChange="handleFetch" | |
| 162 | + @change="handleChange" | |
| 163 | + :options="getOptions" | |
| 164 | + :filter-option="false" | |
| 165 | + @search="debounceSearchFunction" | |
| 166 | + v-model:value="state" | |
| 167 | + > | |
| 168 | + <template #[item]="data" v-for="item in Object.keys($slots)"> | |
| 169 | + <slot :name="item" v-bind="data || {}"></slot> | |
| 170 | + </template> | |
| 171 | + <template #suffixIcon v-if="loading"> | |
| 172 | + <LoadingOutlined spin /> | |
| 173 | + </template> | |
| 174 | + <template #notFoundContent v-if="loading"> | |
| 175 | + <span> | |
| 176 | + <LoadingOutlined spin class="mr-1" /> | |
| 177 | + {{ t('component.form.apiSelectNotFound') }} | |
| 178 | + </span> | |
| 179 | + </template> | |
| 180 | + </Select> | |
| 181 | +</template> | ... | ... |
src/enums/otaEnum.ts
0 → 100644
| ... | ... | @@ -13,11 +13,16 @@ import { OrgTreeSelect } from '/@/views/common/OrgTreeSelect'; |
| 13 | 13 | import { TCPProtocolTypeEnum, TransportTypeEnum } from '/@/enums/deviceEnum'; |
| 14 | 14 | import { HexInput, InputTypeEnum } from '../../profiles/components/ObjectModelForm/HexInput'; |
| 15 | 15 | import { DeviceProfileDetail } from '/@/api/device/model/deviceConfigModel'; |
| 16 | +import ApiQuerySelectVue from '/@/components/Form/src/components/ApiQuerySelect.vue'; | |
| 17 | +import { getDeviceProfileOtaPackages, getOtaPackageInfo } from '/@/api/ota'; | |
| 18 | +import { QueryDeviceProfileOtaPackagesType } from '/@/api/ota/model'; | |
| 19 | +import { OTAPackageType } from '/@/enums/otaEnum'; | |
| 16 | 20 | |
| 17 | 21 | useComponentRegister('JSONEditor', JSONEditor); |
| 18 | 22 | useComponentRegister('LockControlGroup', LockControlGroup); |
| 19 | 23 | useComponentRegister('OrgTreeSelect', OrgTreeSelect); |
| 20 | 24 | useComponentRegister('HexInput', HexInput); |
| 25 | +useComponentRegister('ApiQuerySelect', ApiQuerySelectVue); | |
| 21 | 26 | |
| 22 | 27 | export enum TypeEnum { |
| 23 | 28 | IS_GATEWAY = 'GATEWAY', |
| ... | ... | @@ -115,6 +120,7 @@ export const step1Schemas: FormSchema[] = [ |
| 115 | 120 | component: 'Input', |
| 116 | 121 | show: false, |
| 117 | 122 | }, |
| 123 | + | |
| 118 | 124 | { |
| 119 | 125 | field: 'profileId', |
| 120 | 126 | label: '所属产品', |
| ... | ... | @@ -156,7 +162,6 @@ export const step1Schemas: FormSchema[] = [ |
| 156 | 162 | const { profileId } = formModel; |
| 157 | 163 | if (profileId) { |
| 158 | 164 | const selectRecord = options.find((item) => item.value === profileId); |
| 159 | - | |
| 160 | 165 | selectRecord && |
| 161 | 166 | setFieldsValue({ |
| 162 | 167 | transportType: selectRecord!.transportType, |
| ... | ... | @@ -373,6 +378,65 @@ export const step1Schemas: FormSchema[] = [ |
| 373 | 378 | component: 'Input', |
| 374 | 379 | slot: 'deviceAddress', |
| 375 | 380 | }, |
| 381 | + | |
| 382 | + { | |
| 383 | + field: 'firmwareId', | |
| 384 | + label: '分配的固件', | |
| 385 | + component: 'ApiQuerySelect', | |
| 386 | + ifShow: ({ model }) => model?.isUpdate, | |
| 387 | + componentProps: ({ formModel }) => { | |
| 388 | + return { | |
| 389 | + placeholder: '请选择分配的固件', | |
| 390 | + api: async (params: QueryDeviceProfileOtaPackagesType) => { | |
| 391 | + if (!params.deviceProfileId) return []; | |
| 392 | + const result = await getDeviceProfileOtaPackages(params); | |
| 393 | + return result.data.map((item) => ({ label: item.title, value: item.id.id })); | |
| 394 | + }, | |
| 395 | + params: (textSearch: string) => { | |
| 396 | + return { | |
| 397 | + textSearch, | |
| 398 | + page: 0, | |
| 399 | + type: OTAPackageType.FIRMWARE, | |
| 400 | + pageSize: 10, | |
| 401 | + deviceProfileId: formModel?.profileId, | |
| 402 | + }; | |
| 403 | + }, | |
| 404 | + queryApi: async (id: string) => { | |
| 405 | + const result = await getOtaPackageInfo(id); | |
| 406 | + return { label: result.title, value: result.id.id }; | |
| 407 | + }, | |
| 408 | + }; | |
| 409 | + }, | |
| 410 | + }, | |
| 411 | + { | |
| 412 | + field: 'softwareId', | |
| 413 | + label: '分配的软件', | |
| 414 | + component: 'ApiQuerySelect', | |
| 415 | + ifShow: ({ model }) => model?.isUpdate, | |
| 416 | + componentProps: ({ formModel }) => { | |
| 417 | + return { | |
| 418 | + placeholder: '请选择分配的软件', | |
| 419 | + api: async (params: QueryDeviceProfileOtaPackagesType) => { | |
| 420 | + if (!params.deviceProfileId) return []; | |
| 421 | + const result = await getDeviceProfileOtaPackages(params); | |
| 422 | + return result.data.map((item) => ({ label: item.title, value: item.id.id })); | |
| 423 | + }, | |
| 424 | + params: (textSearch: string) => { | |
| 425 | + return { | |
| 426 | + textSearch, | |
| 427 | + page: 0, | |
| 428 | + type: OTAPackageType.SOFTWARE, | |
| 429 | + pageSize: 10, | |
| 430 | + deviceProfileId: formModel?.profileId, | |
| 431 | + }; | |
| 432 | + }, | |
| 433 | + queryApi: async (id: string) => { | |
| 434 | + const result = await getOtaPackageInfo(id); | |
| 435 | + return { label: result.title, value: result.id.id }; | |
| 436 | + }, | |
| 437 | + }; | |
| 438 | + }, | |
| 439 | + }, | |
| 376 | 440 | { |
| 377 | 441 | field: 'description', |
| 378 | 442 | label: '备注', | ... | ... |