Showing
23 changed files
with
1127 additions
and
32 deletions
| 1 | <!DOCTYPE html> | 1 | <!DOCTYPE html> | 
| 2 | <html lang="en" id="htmlRoot"> | 2 | <html lang="en" id="htmlRoot"> | 
| 3 | <head> | 3 | <head> | 
| 4 | - <%- contentSecurityPolicy %> | ||
| 5 | <meta charset="UTF-8" /> | 4 | <meta charset="UTF-8" /> | 
| 6 | <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" /> | 5 | <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" /> | 
| 7 | <meta name="renderer" content="webkit" /> | 6 | <meta name="renderer" content="webkit" /> | 
src/api/device/batchImport.ts
0 → 100644
| 1 | +import { ImportDeviceParams, ImportDeviceResponse } from './model/batchImportModel'; | ||
| 2 | +import { defHttp } from '/@/utils/http/axios'; | ||
| 3 | + | ||
| 4 | +enum BatchImportApi { | ||
| 5 | + IMPORT = '/device/batch_import', | ||
| 6 | +} | ||
| 7 | + | ||
| 8 | +export const batchImportDevice = (data: ImportDeviceParams) => { | ||
| 9 | + return defHttp.post<ImportDeviceResponse>( | ||
| 10 | + { | ||
| 11 | + url: BatchImportApi.IMPORT, | ||
| 12 | + data, | ||
| 13 | + }, | ||
| 14 | + { joinPrefix: false } | ||
| 15 | + ); | ||
| 16 | +}; | 
src/api/device/model/batchImportModel.ts
0 → 100644
| 1 | +import { DeviceTypeEnum } from './deviceModel'; | ||
| 2 | + | ||
| 3 | +export interface ImportDeviceParams { | ||
| 4 | + file: string; | ||
| 5 | + tkDeviceProfileId: string; | ||
| 6 | + organizationId: string; | ||
| 7 | + deviceTypeEnum: DeviceTypeEnum; | ||
| 8 | + mapping: { | ||
| 9 | + columns: Record<'type', string>[]; | ||
| 10 | + delimiter: string; | ||
| 11 | + header: boolean; | ||
| 12 | + update: boolean; | ||
| 13 | + }; | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +export interface ImportDeviceResponse { | ||
| 17 | + created: number; | ||
| 18 | + updated: number; | ||
| 19 | + errors: number; | ||
| 20 | + errorsList: []; | ||
| 21 | +} | 
| @@ -48,6 +48,8 @@ export interface DeviceModel { | @@ -48,6 +48,8 @@ export interface DeviceModel { | ||
| 48 | label: string; | 48 | label: string; | 
| 49 | lastConnectTime: string; | 49 | lastConnectTime: string; | 
| 50 | deviceType: DeviceTypeEnum; | 50 | deviceType: DeviceTypeEnum; | 
| 51 | + organizationId: string; | ||
| 52 | + customerId?: string; | ||
| 51 | } | 53 | } | 
| 52 | 54 | ||
| 53 | export interface DeviceProfileModel { | 55 | export interface DeviceProfileModel { | 
src/utils/useBatchOperation.ts
0 → 100644
| 1 | +import { computed } from 'vue'; | ||
| 2 | +import { TableActionType } from '/@//components/Table'; | ||
| 3 | + | ||
| 4 | +const useBatchOperation = ( | ||
| 5 | + getRowSelection: TableActionType['getRowSelection'], | ||
| 6 | + setSelectedRowKeys: TableActionType['setSelectedRowKeys'] | ||
| 7 | +) => { | ||
| 8 | + const isExistOption = computed(() => { | ||
| 9 | + const rowSelection = getRowSelection(); | ||
| 10 | + return !!rowSelection.selectedRowKeys?.length; | ||
| 11 | + }); | ||
| 12 | + | ||
| 13 | + const resetSelectedOptions = () => { | ||
| 14 | + setSelectedRowKeys([]); | ||
| 15 | + }; | ||
| 16 | + | ||
| 17 | + return { | ||
| 18 | + isExistOption, | ||
| 19 | + resetSelectedOptions, | ||
| 20 | + }; | ||
| 21 | +}; | ||
| 22 | + | ||
| 23 | +export { useBatchOperation }; | 
| 1 | +<script lang="ts" setup> | ||
| 2 | + import { Button } from 'ant-design-vue'; | ||
| 3 | + import { nextTick, onMounted, ref } from 'vue'; | ||
| 4 | + import { basicInfoForm, FieldsEnum } from './config'; | ||
| 5 | + import { basicProps } from './props'; | ||
| 6 | + import StepContainer from './StepContainer.vue'; | ||
| 7 | + import { BasicForm, useForm } from '/@/components/Form'; | ||
| 8 | + | ||
| 9 | + const props = defineProps({ | ||
| 10 | + ...basicProps, | ||
| 11 | + value: { | ||
| 12 | + required: true, | ||
| 13 | + }, | ||
| 14 | + }); | ||
| 15 | + | ||
| 16 | + const emit = defineEmits(['update:value']); | ||
| 17 | + | ||
| 18 | + const canGoNext = ref(false); | ||
| 19 | + const [register, { getFieldsValue, setFieldsValue }] = useForm({ | ||
| 20 | + schemas: basicInfoForm, | ||
| 21 | + layout: 'vertical', | ||
| 22 | + showActionButtonGroup: false, | ||
| 23 | + submitFunc: async () => { | ||
| 24 | + await nextTick(); | ||
| 25 | + const values = getFieldsValue() || {}; | ||
| 26 | + canGoNext.value = | ||
| 27 | + Reflect.has(values, FieldsEnum.ORGANIZATION_ID) && | ||
| 28 | + Reflect.has(values, FieldsEnum.TK_DEVICE_PROFILE_ID); | ||
| 29 | + }, | ||
| 30 | + }); | ||
| 31 | + | ||
| 32 | + const handleGoNext = () => { | ||
| 33 | + emit('update:value', getFieldsValue()); | ||
| 34 | + props.goNextStep?.(); | ||
| 35 | + }; | ||
| 36 | + | ||
| 37 | + onMounted(() => { | ||
| 38 | + setFieldsValue(props.value || {}); | ||
| 39 | + }); | ||
| 40 | +</script> | ||
| 41 | + | ||
| 42 | +<template> | ||
| 43 | + <StepContainer> | ||
| 44 | + <BasicForm @register="register" /> | ||
| 45 | + <div class="flex justify-end gap-2"> | ||
| 46 | + <Button type="primary" @click="handleGoNext" :disabled="!canGoNext">下一步</Button> | ||
| 47 | + </div> | ||
| 48 | + </StepContainer> | ||
| 49 | +</template> | 
| 1 | +<script lang="ts" setup> | ||
| 2 | + import { Select, Button } from 'ant-design-vue'; | ||
| 3 | + import { basicProps } from './props'; | ||
| 4 | + import StepContainer from './StepContainer.vue'; | ||
| 5 | + import { BasicTable, useTable } from '/@/components/Table'; | ||
| 6 | + import { | ||
| 7 | + columnTypeSchema, | ||
| 8 | + ColumnFileEnum, | ||
| 9 | + ColumTypeEnum, | ||
| 10 | + generateColumnTypeOptions, | ||
| 11 | + } from './config'; | ||
| 12 | + import { nextTick, onMounted, ref, unref, watch } from 'vue'; | ||
| 13 | + import { ColumnDataRecord, Options, UploadFileParseValue } from './type'; | ||
| 14 | + import { DEVICE_NAME_INDEX } from './const'; | ||
| 15 | + import { buildUUID } from '/@/utils/uuid'; | ||
| 16 | + | ||
| 17 | + const props = defineProps({ | ||
| 18 | + ...basicProps, | ||
| 19 | + value: { | ||
| 20 | + required: true, | ||
| 21 | + type: Array as PropType<Record<'type', string>[]>, | ||
| 22 | + }, | ||
| 23 | + fileParseValue: { | ||
| 24 | + require: true, | ||
| 25 | + type: Object as PropType<UploadFileParseValue>, | ||
| 26 | + }, | ||
| 27 | + }); | ||
| 28 | + | ||
| 29 | + const emit = defineEmits(['update:value']); | ||
| 30 | + | ||
| 31 | + const columnTypeRepeatWhiteList = [ | ||
| 32 | + ColumTypeEnum.SERVER_ATTRIBUTE, | ||
| 33 | + ColumTypeEnum.SHARED_ATTRIBUTE, | ||
| 34 | + ColumTypeEnum.TIMESERIES, | ||
| 35 | + ]; | ||
| 36 | + | ||
| 37 | + const [register, { setProps, getDataSource }] = useTable({ | ||
| 38 | + size: 'small', | ||
| 39 | + rowKey: 'id', | ||
| 40 | + showIndexColumn: false, | ||
| 41 | + columns: columnTypeSchema, | ||
| 42 | + resizeHeightOffset: -300, | ||
| 43 | + }); | ||
| 44 | + | ||
| 45 | + const parseValueToTableData = () => { | ||
| 46 | + const { header = [], content = [] } = props.fileParseValue || {}; | ||
| 47 | + const exampleRow = content.at(0) || {}; | ||
| 48 | + | ||
| 49 | + const getColumnType = (index: number) => { | ||
| 50 | + return index === DEVICE_NAME_INDEX ? ColumTypeEnum.NAME : ColumTypeEnum.SERVER_ATTRIBUTE; | ||
| 51 | + }; | ||
| 52 | + | ||
| 53 | + const dataSource = header.map((columnKey, index) => { | ||
| 54 | + return { | ||
| 55 | + [ColumnFileEnum.EXAMPLE_VALUE]: exampleRow[columnKey], | ||
| 56 | + [ColumnFileEnum.TYPE]: getColumnType(index), | ||
| 57 | + [ColumnFileEnum.KEY]: columnKey, | ||
| 58 | + id: buildUUID(), | ||
| 59 | + } as ColumnDataRecord; | ||
| 60 | + }); | ||
| 61 | + | ||
| 62 | + setProps({ | ||
| 63 | + dataSource, | ||
| 64 | + pagination: { pageSize: dataSource.length }, | ||
| 65 | + maxHeight: 40 * dataSource.length, | ||
| 66 | + }); | ||
| 67 | + }; | ||
| 68 | + | ||
| 69 | + const columnTypeOptions = ref(generateColumnTypeOptions()); | ||
| 70 | + | ||
| 71 | + const handleDisableSelectedOption = () => { | ||
| 72 | + let dataSource = getDataSource().reduce<string[]>( | ||
| 73 | + (prev, next) => [...prev, next[ColumnFileEnum.TYPE], next[ColumnFileEnum.OLD_VALUE]], | ||
| 74 | + [] | ||
| 75 | + ); | ||
| 76 | + | ||
| 77 | + dataSource = [...new Set(dataSource.filter(Boolean))]; | ||
| 78 | + | ||
| 79 | + const disableSelectedOptions = ( | ||
| 80 | + options: Options[], | ||
| 81 | + whiteList: string[] = columnTypeRepeatWhiteList | ||
| 82 | + ) => { | ||
| 83 | + for (const item of options) { | ||
| 84 | + if (item.options?.length) { | ||
| 85 | + disableSelectedOptions(item.options); | ||
| 86 | + continue; | ||
| 87 | + } | ||
| 88 | + if (whiteList.includes(item.value!)) continue; | ||
| 89 | + item.disabled = dataSource.includes(item.value!); | ||
| 90 | + } | ||
| 91 | + }; | ||
| 92 | + | ||
| 93 | + disableSelectedOptions(unref(columnTypeOptions)); | ||
| 94 | + }; | ||
| 95 | + | ||
| 96 | + const getColumnNeedDisabled = (index: number) => { | ||
| 97 | + return index === DEVICE_NAME_INDEX; | ||
| 98 | + }; | ||
| 99 | + | ||
| 100 | + const handleGoPreviousStep = () => { | ||
| 101 | + props.goPreviousStep?.(); | ||
| 102 | + }; | ||
| 103 | + | ||
| 104 | + const handleGoNextStep = () => { | ||
| 105 | + const columns = getDataSource<ColumnDataRecord>().map((item) => ({ type: item.type })); | ||
| 106 | + emit('update:value', columns); | ||
| 107 | + props.goNextStep?.(); | ||
| 108 | + }; | ||
| 109 | + | ||
| 110 | + watch( | ||
| 111 | + () => props.fileParseValue, | ||
| 112 | + () => { | ||
| 113 | + parseValueToTableData(); | ||
| 114 | + } | ||
| 115 | + ); | ||
| 116 | + | ||
| 117 | + onMounted(async () => { | ||
| 118 | + parseValueToTableData(); | ||
| 119 | + await nextTick(); | ||
| 120 | + handleDisableSelectedOption(); | ||
| 121 | + }); | ||
| 122 | +</script> | ||
| 123 | + | ||
| 124 | +<template> | ||
| 125 | + <StepContainer> | ||
| 126 | + <BasicTable class="import-device-column-type-table" @register="register"> | ||
| 127 | + <template #type="{ record, index }"> | ||
| 128 | + <section | ||
| 129 | + class="select-column-type-container flex relative" | ||
| 130 | + :class="!record.editable && 'justify-center'" | ||
| 131 | + > | ||
| 132 | + <Select | ||
| 133 | + v-model:value="record[ColumnFileEnum.TYPE]" | ||
| 134 | + @change="handleDisableSelectedOption" | ||
| 135 | + :disabled="getColumnNeedDisabled(index)" | ||
| 136 | + :options="columnTypeOptions" | ||
| 137 | + :dropdown-match-select-width="false" | ||
| 138 | + /> | ||
| 139 | + </section> | ||
| 140 | + </template> | ||
| 141 | + <template #key="{ record }"> | ||
| 142 | + <div> | ||
| 143 | + <span>{{ record[ColumnFileEnum.KEY] }}</span> | ||
| 144 | + </div> | ||
| 145 | + </template> | ||
| 146 | + </BasicTable> | ||
| 147 | + <section class="flex justify-end gap-4 mt-4"> | ||
| 148 | + <Button type="primary" @click="handleGoPreviousStep">上一步</Button> | ||
| 149 | + <Button type="primary" @click="handleGoNextStep">下一步</Button> | ||
| 150 | + </section> | ||
| 151 | + </StepContainer> | ||
| 152 | +</template> | ||
| 153 | + | ||
| 154 | +<style lang="less" scoped> | ||
| 155 | + .import-device-column-type-table { | ||
| 156 | + .ant-select { | ||
| 157 | + width: 80%; | ||
| 158 | + } | ||
| 159 | + } | ||
| 160 | +</style> | ||
| 161 | + | ||
| 162 | +<style lang="less"> | ||
| 163 | + .import-device-column-type-table { | ||
| 164 | + .ant-select, | ||
| 165 | + .ant-input, | ||
| 166 | + .ant-select-selector { | ||
| 167 | + height: 22px !important; | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + .ant-select-selection-item { | ||
| 171 | + line-height: 22px !important; | ||
| 172 | + } | ||
| 173 | + } | ||
| 174 | +</style> | 
| 1 | +<script lang="ts" setup> | ||
| 2 | + import StepContainer from './StepContainer.vue'; | ||
| 3 | + import { ImportDeviceResponse } from '/@/api/device/model/batchImportModel'; | ||
| 4 | + | ||
| 5 | + const props = defineProps({ | ||
| 6 | + value: { | ||
| 7 | + required: true, | ||
| 8 | + type: Object as PropType<ImportDeviceResponse>, | ||
| 9 | + }, | ||
| 10 | + }); | ||
| 11 | +</script> | ||
| 12 | + | ||
| 13 | +<template> | ||
| 14 | + <StepContainer> | ||
| 15 | + <div>创建成功:{{ props.value.created }}</div> | ||
| 16 | + <div>错误:{{ props.value.errors }}</div> | ||
| 17 | + <div>错误列表:{{ props.value.errorsList }}</div> | ||
| 18 | + <div>更新:{{ props.value.updated }}</div> | ||
| 19 | + </StepContainer> | ||
| 20 | +</template> | 
| 1 | +<script lang="ts" setup> | ||
| 2 | + import { Spin } from 'ant-design-vue'; | ||
| 3 | + import { onMounted, ref } from 'vue'; | ||
| 4 | + import { DelimiterEnum } from './config'; | ||
| 5 | + import { basicProps } from './props'; | ||
| 6 | + import StepContainer from './StepContainer.vue'; | ||
| 7 | + import { CreateEntityValue, UploadFileParseValue } from './type'; | ||
| 8 | + import { batchImportDevice } from '/@/api/device/batchImport'; | ||
| 9 | + import { ImportDeviceParams, ImportDeviceResponse } from '/@/api/device/model/batchImportModel'; | ||
| 10 | + | ||
| 11 | + const props = defineProps({ | ||
| 12 | + ...basicProps, | ||
| 13 | + value: { | ||
| 14 | + required: true, | ||
| 15 | + type: Object as PropType<CreateEntityValue>, | ||
| 16 | + }, | ||
| 17 | + result: { | ||
| 18 | + required: true, | ||
| 19 | + type: Object as PropType<ImportDeviceResponse>, | ||
| 20 | + }, | ||
| 21 | + }); | ||
| 22 | + | ||
| 23 | + const emit = defineEmits(['update:result']); | ||
| 24 | + | ||
| 25 | + const spinning = ref(true); | ||
| 26 | + | ||
| 27 | + const sleep = (time: number) => { | ||
| 28 | + return new Promise((resolve) => { | ||
| 29 | + setTimeout(() => { | ||
| 30 | + resolve(time); | ||
| 31 | + }, time); | ||
| 32 | + }); | ||
| 33 | + }; | ||
| 34 | + | ||
| 35 | + const submit = async () => { | ||
| 36 | + spinning.value = true; | ||
| 37 | + try { | ||
| 38 | + const value = transfromData(JSON.parse(JSON.stringify(props.value))); | ||
| 39 | + const result = await batchImportDevice(value); | ||
| 40 | + await sleep(3000); | ||
| 41 | + emit('update:result', result); | ||
| 42 | + props.goNextStep?.(); | ||
| 43 | + } catch (error) { | ||
| 44 | + throw error; | ||
| 45 | + } finally { | ||
| 46 | + spinning.value = false; | ||
| 47 | + } | ||
| 48 | + }; | ||
| 49 | + | ||
| 50 | + const insertDeviceTypeName = ( | ||
| 51 | + deviceTypeName: string, | ||
| 52 | + fileParseValue: UploadFileParseValue, | ||
| 53 | + columns: Record<'type', string>[] | ||
| 54 | + ): { file: string; columns: Record<'type', string>[] } => { | ||
| 55 | + const { header, content } = fileParseValue; | ||
| 56 | + const insertIndex = 1; | ||
| 57 | + | ||
| 58 | + const csvArray = content.map((item) => header.map((key) => item[key]) as string[]); | ||
| 59 | + for (const item of csvArray) { | ||
| 60 | + item.splice(insertIndex, 0, deviceTypeName); | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + const _header = [...header]; | ||
| 64 | + _header.splice(insertIndex, 0, deviceTypeName); | ||
| 65 | + csvArray.unshift(_header); | ||
| 66 | + const file = csvArray.map((item) => item.join(DelimiterEnum.COMMA)).join('\n'); | ||
| 67 | + | ||
| 68 | + columns.splice(insertIndex, 0, { type: 'TYPE' }); | ||
| 69 | + | ||
| 70 | + return { | ||
| 71 | + file, | ||
| 72 | + columns, | ||
| 73 | + }; | ||
| 74 | + }; | ||
| 75 | + | ||
| 76 | + const transfromData = (data: CreateEntityValue) => { | ||
| 77 | + const { basicInfo, columnConfiguration, fileParseValue } = data; | ||
| 78 | + const { tkDeviceProfileId, organizationId, deviceTypeEnum, deviceTypeName } = basicInfo; | ||
| 79 | + const { file, columns } = insertDeviceTypeName( | ||
| 80 | + deviceTypeName, | ||
| 81 | + fileParseValue, | ||
| 82 | + columnConfiguration | ||
| 83 | + ); | ||
| 84 | + return { | ||
| 85 | + file, | ||
| 86 | + tkDeviceProfileId, | ||
| 87 | + organizationId, | ||
| 88 | + deviceTypeEnum, | ||
| 89 | + mapping: { | ||
| 90 | + columns: columns, | ||
| 91 | + delimiter: DelimiterEnum.COMMA, | ||
| 92 | + header: true, | ||
| 93 | + update: true, | ||
| 94 | + }, | ||
| 95 | + } as ImportDeviceParams; | ||
| 96 | + }; | ||
| 97 | + | ||
| 98 | + onMounted(() => { | ||
| 99 | + submit(); | ||
| 100 | + }); | ||
| 101 | +</script> | ||
| 102 | + | ||
| 103 | +<template> | ||
| 104 | + <StepContainer class="flex justify-center"> | ||
| 105 | + <Spin :spinning="spinning" tip="正在创建..." /> | ||
| 106 | + </StepContainer> | ||
| 107 | +</template> | 
| 1 | +<script lang="ts" setup> | ||
| 2 | + import { Button } from 'ant-design-vue'; | ||
| 3 | + import { importConfigurationSchema } from './config'; | ||
| 4 | + import { basicProps } from './props'; | ||
| 5 | + import StepContainer from './StepContainer.vue'; | ||
| 6 | + import { BasicForm, useForm } from '/@/components/Form'; | ||
| 7 | + const props = defineProps({ | ||
| 8 | + ...basicProps, | ||
| 9 | + }); | ||
| 10 | + | ||
| 11 | + const [register] = useForm({ | ||
| 12 | + schemas: importConfigurationSchema, | ||
| 13 | + showActionButtonGroup: false, | ||
| 14 | + labelWidth: 120, | ||
| 15 | + layout: 'vertical', | ||
| 16 | + }); | ||
| 17 | + | ||
| 18 | + const handlePreviousStep = () => { | ||
| 19 | + props.goNextStep?.(); | ||
| 20 | + }; | ||
| 21 | + | ||
| 22 | + const handleNextStep = () => { | ||
| 23 | + props.goNextStep?.(); | ||
| 24 | + }; | ||
| 25 | +</script> | ||
| 26 | + | ||
| 27 | +<template> | ||
| 28 | + <StepContainer> | ||
| 29 | + <BasicForm class="import-configuration-form" @register="register" /> | ||
| 30 | + <div class="flex justify-end gap-4 mt-4"> | ||
| 31 | + <Button type="primary" @click="handlePreviousStep">上一步</Button> | ||
| 32 | + <Button type="primary" @click="handleNextStep">下一步</Button> | ||
| 33 | + </div> | ||
| 34 | + </StepContainer> | ||
| 35 | +</template> | ||
| 36 | + | ||
| 37 | +<style lang="less"> | ||
| 38 | + .import-configuration-form { | ||
| 39 | + .ant-row { | ||
| 40 | + > .ant-col:nth-of-type(2), | ||
| 41 | + > .ant-col:nth-of-type(3) { | ||
| 42 | + > .ant-row { | ||
| 43 | + flex-direction: row; | ||
| 44 | + align-items: center; | ||
| 45 | + | ||
| 46 | + .ant-form-item-label { | ||
| 47 | + padding: 0 !important; | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + } | ||
| 53 | +</style> | 
| 1 | +<script lang="ts" setup> | ||
| 2 | + import { Upload, Button } from 'ant-design-vue'; | ||
| 3 | + import { InboxOutlined } from '@ant-design/icons-vue'; | ||
| 4 | + import { computed, ref } from 'vue'; | ||
| 5 | + import StepContainer from './StepContainer.vue'; | ||
| 6 | + import XLSX, { CellObject } from 'xlsx'; | ||
| 7 | + import { basicProps } from './props'; | ||
| 8 | + import { UploadFileParseValue } from './type'; | ||
| 9 | + | ||
| 10 | + const props = defineProps({ | ||
| 11 | + ...basicProps, | ||
| 12 | + value: { | ||
| 13 | + require: true, | ||
| 14 | + type: Object as PropType<UploadFileParseValue>, | ||
| 15 | + }, | ||
| 16 | + }); | ||
| 17 | + | ||
| 18 | + const emit = defineEmits(['update:value']); | ||
| 19 | + | ||
| 20 | + const fileList = ref<Record<'uid' | 'name', string>[]>([]); | ||
| 21 | + | ||
| 22 | + interface FileRequestParams { | ||
| 23 | + file: File & { uid: string }; | ||
| 24 | + onSuccess: Fn; | ||
| 25 | + onProgress: Fn; | ||
| 26 | + onError: Fn; | ||
| 27 | + status: string; | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + const readFile = async (file: File): Promise<UploadFileParseValue | boolean> => { | ||
| 31 | + /** | ||
| 32 | + * @description 读取表头 | ||
| 33 | + * @param sheet 工作表 | ||
| 34 | + * @param range 区间 | ||
| 35 | + * @param headerRow 表头行 | ||
| 36 | + */ | ||
| 37 | + const getTableHeader = (sheet: XLSX.WorkSheet, range: [number, number], headerRow = 1) => { | ||
| 38 | + const [startColumn, endColumn] = range; | ||
| 39 | + const header: string[] = []; | ||
| 40 | + for (let i = startColumn; i <= endColumn; i++) { | ||
| 41 | + const columnIndex = XLSX.utils.encode_col(i) + headerRow; | ||
| 42 | + const value = (sheet[columnIndex] as CellObject).v; | ||
| 43 | + header.push(value as string); | ||
| 44 | + } | ||
| 45 | + return header; | ||
| 46 | + }; | ||
| 47 | + | ||
| 48 | + return new Promise((resolve, reject) => { | ||
| 49 | + const fileReader = new FileReader(); | ||
| 50 | + fileReader.onload = (event: ProgressEvent) => { | ||
| 51 | + const data = (event.target as FileReader).result as string; | ||
| 52 | + const result = XLSX.read(data, { type: 'string' }); | ||
| 53 | + | ||
| 54 | + const sheetName = result.SheetNames.at(0); | ||
| 55 | + const workbook = result.Sheets; | ||
| 56 | + const sheet = workbook[sheetName as string]; | ||
| 57 | + const sheetRange = sheet['!ref']; | ||
| 58 | + | ||
| 59 | + const { | ||
| 60 | + s: { c: startColumn }, | ||
| 61 | + e: { c: endColumn }, | ||
| 62 | + } = XLSX.utils.decode_range(sheetRange!); | ||
| 63 | + | ||
| 64 | + const header = getTableHeader(sheet, [startColumn, endColumn]); | ||
| 65 | + const content = XLSX.utils.sheet_to_json(sheet, { range: sheetRange }) as Recordable[]; | ||
| 66 | + resolve({ header, content }); | ||
| 67 | + }; | ||
| 68 | + | ||
| 69 | + fileReader.onerror = () => { | ||
| 70 | + reject(false); | ||
| 71 | + }; | ||
| 72 | + fileReader.readAsText(file); | ||
| 73 | + }); | ||
| 74 | + }; | ||
| 75 | + | ||
| 76 | + const handleParseFile = async ({ file, onSuccess, onError }: FileRequestParams) => { | ||
| 77 | + fileList.value = []; | ||
| 78 | + const value = await readFile(file); | ||
| 79 | + if (!value) { | ||
| 80 | + onError(); | ||
| 81 | + return; | ||
| 82 | + } | ||
| 83 | + fileList.value = [file]; | ||
| 84 | + emit('update:value', value); | ||
| 85 | + onSuccess({}, file); | ||
| 86 | + }; | ||
| 87 | + | ||
| 88 | + const canGoNextStep = computed(() => { | ||
| 89 | + return !!fileList.value.length; | ||
| 90 | + }); | ||
| 91 | + | ||
| 92 | + const handlePreviousStep = () => { | ||
| 93 | + props.goPreviousStep?.(); | ||
| 94 | + }; | ||
| 95 | + | ||
| 96 | + const handleNextStep = () => { | ||
| 97 | + props.goNextStep?.(); | ||
| 98 | + }; | ||
| 99 | +</script> | ||
| 100 | + | ||
| 101 | +<template> | ||
| 102 | + <StepContainer> | ||
| 103 | + <div class="">设备文件</div> | ||
| 104 | + <Upload.Dragger :fileList="fileList" :customRequest="handleParseFile" accept=".csv" name="file"> | ||
| 105 | + <section class="cursor-pointer flex flex-col justify-center items-center"> | ||
| 106 | + <InboxOutlined class="text-[4rem] !text-blue-400" /> | ||
| 107 | + <div class="text-gray-500">点击上传或拖拽上传</div> | ||
| 108 | + </section> | ||
| 109 | + </Upload.Dragger> | ||
| 110 | + <div class="flex justify-end gap-4 mt-4"> | ||
| 111 | + <Button type="primary" @click="handlePreviousStep">上一步</Button> | ||
| 112 | + <Button type="primary" @click="handleNextStep" :disabled="!canGoNextStep">下一步</Button> | ||
| 113 | + </div> | ||
| 114 | + </StepContainer> | ||
| 115 | +</template> | 
| 1 | +import { Options } from './type'; | ||
| 2 | +import { getDeviceProfile } from '/@/api/alarm/position'; | ||
| 3 | +import { getOrganizationList } from '/@/api/system/system'; | ||
| 4 | +import { FormSchema } from '/@/components/Form'; | ||
| 5 | +import { BasicColumn } from '/@/components/Table'; | ||
| 6 | +import { copyTransFun } from '/@/utils/fnUtils'; | ||
| 7 | + | ||
| 8 | +export enum FieldsEnum { | ||
| 9 | + ORGANIZATION_ID = 'organizationId', | ||
| 10 | + TK_DEVICE_PROFILE_ID = 'tkDeviceProfileId', | ||
| 11 | + DEVICE_TYPE_ENUM = 'deviceTypeEnum', | ||
| 12 | + DEVICE_TYPE_NAME = 'deviceTypeName', | ||
| 13 | + DELIMITER = 'delimiter', | ||
| 14 | + HEADER = 'header', | ||
| 15 | + UPDATE = 'update', | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +export enum DelimiterEnum { | ||
| 19 | + COMMA = ',', | ||
| 20 | + SEMICOLON = ';', | ||
| 21 | + VERTICAL_LINE = '|', | ||
| 22 | + TAB = 'Tab', | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +export enum DelimiterNameEnum { | ||
| 26 | + ',' = 'COMMA', | ||
| 27 | + ';' = 'SEMICOLON', | ||
| 28 | + '|' = 'VERTICAL_LINE', | ||
| 29 | + 'Tab' = 'TAB', | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +export enum ColumnFileEnum { | ||
| 33 | + EXAMPLE_VALUE = 'exampleValue', | ||
| 34 | + TYPE = 'type', | ||
| 35 | + KEY = 'key', | ||
| 36 | + OLD_VALUE = 'oldValue', | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +export enum ColumTypeEnum { | ||
| 40 | + NAME = 'NAME', | ||
| 41 | + // TYPE = 'TYPE', | ||
| 42 | + LABEL = 'LABEL', | ||
| 43 | + DESCRIPTION = 'DESCRIPTION', | ||
| 44 | + SHARED_ATTRIBUTE = 'SHARED_ATTRIBUTE', | ||
| 45 | + SERVER_ATTRIBUTE = 'SERVER_ATTRIBUTE', | ||
| 46 | + TIMESERIES = 'TIMESERIES', | ||
| 47 | + IS_GATEWAY = 'IS_GATEWAY', | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +export enum CredentialsEnum { | ||
| 51 | + ACCESS_TOKEN = 'ACCESS_TOKEN', | ||
| 52 | + X509 = 'X509', | ||
| 53 | + MQTT_CLIENT_ID = 'MQTT_CLIENT_ID', | ||
| 54 | + MQTT_USER_NAME = 'MQTT_USER_NAME', | ||
| 55 | + MQTT_PASSWORD = 'MQTT_PASSWORD', | ||
| 56 | + LWM2M_CLIENT_ENDPOINT = 'LWM2M_CLIENT_ENDPOINT', | ||
| 57 | + LWM2M_CLIENT_SECURITY_CONFIG_MODE = 'LWM2M_CLIENT_SECURITY_CONFIG_MODE', | ||
| 58 | + LWM2M_CLIENT_IDENTITY = 'LWM2M_CLIENT_IDENTITY', | ||
| 59 | + LWM2M_CLIENT_KEY = 'LWM2M_CLIENT_KEY', | ||
| 60 | + LWM2M_CLIENT_CERT = 'LWM2M_CLIENT_CERT', | ||
| 61 | + LWM2M_BOOTSTRAP_SERVER_SECURITY_MODE = 'LWM2M_BOOTSTRAP_SERVER_SECURITY_MODE', | ||
| 62 | + LWM2M_BOOTSTRAP_SERVER_PUBLIC_KEY_OR_ID = 'LWM2M_BOOTSTRAP_SERVER_PUBLIC_KEY_OR_ID', | ||
| 63 | + LWM2M_BOOTSTRAP_SERVER_SECRET_KEY = 'LWM2M_BOOTSTRAP_SERVER_SECRET_KEY', | ||
| 64 | + LWM2M_SERVER_SECURITY_MODE = 'LWM2M_SERVER_SECURITY_MODE', | ||
| 65 | + LWM2M_SERVER_CLIENT_PUBLIC_KEY_OR_ID = 'LWM2M_SERVER_CLIENT_PUBLIC_KEY_OR_ID', | ||
| 66 | + LWM2M_SERVER_CLIENT_SECRET_KEY = 'LWM2M_SERVER_CLIENT_SECRET_KEY', | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +export enum ColumnTypeNameEnum { | ||
| 70 | + NAME = '名称', | ||
| 71 | + // TYPE = '类型', | ||
| 72 | + LABEL = '标签', | ||
| 73 | + DESCRIPTION = '说明', | ||
| 74 | + SHARED_ATTRIBUTE = '共享属性', | ||
| 75 | + SERVER_ATTRIBUTE = '服务器属性', | ||
| 76 | + TIMESERIES = 'Timeseries', | ||
| 77 | + IS_GATEWAY = 'Is网关', | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +export enum CredentialsNameEnum { | ||
| 81 | + ACCESS_TOKEN = '访问令牌', | ||
| 82 | + X509 = 'X.509', | ||
| 83 | + MQTT_CLIENT_ID = 'MQTT client ID', | ||
| 84 | + MQTT_USER_NAME = 'MQTT user name', | ||
| 85 | + MQTT_PASSWORD = 'MQTT password', | ||
| 86 | + LWM2M_CLIENT_ENDPOINT = 'LwM2M endpoint client name', | ||
| 87 | + LWM2M_CLIENT_SECURITY_CONFIG_MODE = 'LwM2M security config mode', | ||
| 88 | + LWM2M_CLIENT_IDENTITY = 'LwM2M client identity', | ||
| 89 | + LWM2M_CLIENT_KEY = 'LwM2M client key', | ||
| 90 | + LWM2M_CLIENT_CERT = 'LwM2M client public key', | ||
| 91 | + LWM2M_BOOTSTRAP_SERVER_SECURITY_MODE = 'LwM2M bootstrap server security mode', | ||
| 92 | + LWM2M_BOOTSTRAP_SERVER_PUBLIC_KEY_OR_ID = 'LwM2M bootstrap server public key or id', | ||
| 93 | + LWM2M_BOOTSTRAP_SERVER_SECRET_KEY = 'LwM2M bootstrap server secret key', | ||
| 94 | + LWM2M_SERVER_SECURITY_MODE = 'LwM2M server security mode', | ||
| 95 | + LWM2M_SERVER_CLIENT_PUBLIC_KEY_OR_ID = 'LwM2M server public key or id', | ||
| 96 | + LWM2M_SERVER_CLIENT_SECRET_KEY = 'LwM2M server secret key', | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +export const basicInfoForm: FormSchema[] = [ | ||
| 100 | + { | ||
| 101 | + field: FieldsEnum.ORGANIZATION_ID, | ||
| 102 | + label: '所属组织', | ||
| 103 | + component: 'ApiTreeSelect', | ||
| 104 | + rules: [{ required: true, message: '所属组织为必填项' }], | ||
| 105 | + componentProps: ({ formActionType }) => { | ||
| 106 | + const { submit } = formActionType; | ||
| 107 | + return { | ||
| 108 | + maxLength: 250, | ||
| 109 | + placeholder: '请选择所属组织', | ||
| 110 | + api: async () => { | ||
| 111 | + const data = (await getOrganizationList()) as unknown as Recordable[]; | ||
| 112 | + copyTransFun(data); | ||
| 113 | + return data; | ||
| 114 | + }, | ||
| 115 | + getPopupContainer: () => document.body, | ||
| 116 | + onChange: () => submit(), | ||
| 117 | + }; | ||
| 118 | + }, | ||
| 119 | + }, | ||
| 120 | + { | ||
| 121 | + field: FieldsEnum.TK_DEVICE_PROFILE_ID, | ||
| 122 | + component: 'ApiSelect', | ||
| 123 | + label: '产品', | ||
| 124 | + rules: [{ required: true, message: '产品为必填项' }], | ||
| 125 | + componentProps: ({ formActionType }) => { | ||
| 126 | + const { submit, setFieldsValue } = formActionType; | ||
| 127 | + return { | ||
| 128 | + api: getDeviceProfile, | ||
| 129 | + labelField: 'name', | ||
| 130 | + valueField: 'id', | ||
| 131 | + placeholder: '请选择产品', | ||
| 132 | + getPopupContainer: () => document.body, | ||
| 133 | + onChange: (value: string, options: Record<'deviceType' | 'label', string>) => { | ||
| 134 | + value && | ||
| 135 | + setFieldsValue({ | ||
| 136 | + [FieldsEnum.DEVICE_TYPE_ENUM]: options.deviceType, | ||
| 137 | + [FieldsEnum.DEVICE_TYPE_NAME]: options.label, | ||
| 138 | + }); | ||
| 139 | + submit(); | ||
| 140 | + }, | ||
| 141 | + }; | ||
| 142 | + }, | ||
| 143 | + }, | ||
| 144 | + { | ||
| 145 | + field: FieldsEnum.DEVICE_TYPE_ENUM, | ||
| 146 | + component: 'Input', | ||
| 147 | + label: '设备类型', | ||
| 148 | + show: false, | ||
| 149 | + }, | ||
| 150 | + { | ||
| 151 | + field: FieldsEnum.DEVICE_TYPE_NAME, | ||
| 152 | + component: 'Input', | ||
| 153 | + label: '设备名称', | ||
| 154 | + show: false, | ||
| 155 | + }, | ||
| 156 | +]; | ||
| 157 | + | ||
| 158 | +export const importConfigurationSchema: FormSchema[] = [ | ||
| 159 | + { | ||
| 160 | + field: FieldsEnum.DELIMITER, | ||
| 161 | + label: 'csv分隔符', | ||
| 162 | + component: 'Select', | ||
| 163 | + rules: [{ required: true, message: 'csv分隔符为必填项' }], | ||
| 164 | + defaultValue: DelimiterEnum.COMMA, | ||
| 165 | + componentProps: { | ||
| 166 | + options: [ | ||
| 167 | + { label: DelimiterEnum.COMMA, value: DelimiterEnum.COMMA }, | ||
| 168 | + { label: DelimiterEnum.SEMICOLON, value: DelimiterEnum.SEMICOLON }, | ||
| 169 | + { label: DelimiterEnum.VERTICAL_LINE, value: DelimiterEnum.VERTICAL_LINE }, | ||
| 170 | + { label: DelimiterEnum.TAB, value: DelimiterEnum.TAB }, | ||
| 171 | + ], | ||
| 172 | + }, | ||
| 173 | + }, | ||
| 174 | + { | ||
| 175 | + field: FieldsEnum.HEADER, | ||
| 176 | + label: '第一行包含列名', | ||
| 177 | + component: 'Checkbox', | ||
| 178 | + defaultValue: true, | ||
| 179 | + }, | ||
| 180 | + { | ||
| 181 | + field: FieldsEnum.UPDATE, | ||
| 182 | + label: '更新属性/遥测', | ||
| 183 | + component: 'Checkbox', | ||
| 184 | + defaultValue: true, | ||
| 185 | + }, | ||
| 186 | +]; | ||
| 187 | + | ||
| 188 | +export const generateColumnTypeOptions = () => { | ||
| 189 | + const valueOptions = Object.keys(ColumTypeEnum); | ||
| 190 | + const labelOptions = Object.values(ColumnTypeNameEnum); | ||
| 191 | + const credentialsValueOptions = Object.keys(CredentialsEnum); | ||
| 192 | + const credentialsNameOptions = Object.values(CredentialsNameEnum); | ||
| 193 | + const options: Options[] = valueOptions.map((value, index) => ({ | ||
| 194 | + label: labelOptions[index], | ||
| 195 | + value, | ||
| 196 | + })); | ||
| 197 | + | ||
| 198 | + const credentialsOption: Options = { | ||
| 199 | + label: 'credentials', | ||
| 200 | + options: credentialsValueOptions.map((value, index) => ({ | ||
| 201 | + label: credentialsNameOptions[index], | ||
| 202 | + value: value, | ||
| 203 | + })) as Options[], | ||
| 204 | + }; | ||
| 205 | + options.push(credentialsOption); | ||
| 206 | + | ||
| 207 | + return options; | ||
| 208 | +}; | ||
| 209 | + | ||
| 210 | +export const columnTypeSchema: BasicColumn[] = [ | ||
| 211 | + { | ||
| 212 | + title: '列名称', | ||
| 213 | + key: ColumnFileEnum.KEY, | ||
| 214 | + dataIndex: ColumnFileEnum.KEY, | ||
| 215 | + slots: { | ||
| 216 | + customRender: ColumnFileEnum.KEY, | ||
| 217 | + }, | ||
| 218 | + }, | ||
| 219 | + { | ||
| 220 | + title: '列类型', | ||
| 221 | + dataIndex: ColumnFileEnum.TYPE, | ||
| 222 | + key: ColumnFileEnum.TYPE, | ||
| 223 | + slots: { | ||
| 224 | + customRender: ColumnFileEnum.TYPE, | ||
| 225 | + }, | ||
| 226 | + }, | ||
| 227 | +]; | ||
| 228 | + | ||
| 229 | +export const assemblyData = () => {}; | 
| 1 | +<script lang="ts" setup> | ||
| 2 | + import { computed, ref, unref } from 'vue'; | ||
| 3 | + import { BasicModal, useModal } from '/@/components/Modal'; | ||
| 4 | + import { Steps } from 'ant-design-vue'; | ||
| 5 | + import BasicInfoForm from './BasicInfoForm.vue'; | ||
| 6 | + import ImportCsv from './ImportCsv.vue'; | ||
| 7 | + import { CreateEntityValue, UploadFileParseValue } from './type'; | ||
| 8 | + import ColumnType from './ColumnType.vue'; | ||
| 9 | + import CreateEntity from './CreateEntity.vue'; | ||
| 10 | + import CompleteResult from './CompleteResult.vue'; | ||
| 11 | + import { ImportDeviceResponse } from '/@/api/device/model/batchImportModel'; | ||
| 12 | + | ||
| 13 | + const emit = defineEmits(['importFinally']); | ||
| 14 | + | ||
| 15 | + const [register] = useModal(); | ||
| 16 | + | ||
| 17 | + const basicInfo = ref({}); | ||
| 18 | + | ||
| 19 | + const fileParseValue = ref<UploadFileParseValue>({ header: [], content: [] }); | ||
| 20 | + | ||
| 21 | + const columnConfiguration = ref<Record<'type', string>[]>([]); | ||
| 22 | + | ||
| 23 | + const importResult = ref<ImportDeviceResponse>({} as unknown as ImportDeviceResponse); | ||
| 24 | + | ||
| 25 | + const assemblyData = computed(() => { | ||
| 26 | + return { | ||
| 27 | + basicInfo: unref(basicInfo), | ||
| 28 | + fileParseValue: unref(fileParseValue), | ||
| 29 | + columnConfiguration: unref(columnConfiguration), | ||
| 30 | + } as CreateEntityValue; | ||
| 31 | + }); | ||
| 32 | + | ||
| 33 | + enum StepsEnum { | ||
| 34 | + BASIC_INFO, | ||
| 35 | + IMPORT_FILE, | ||
| 36 | + COLUMN_CONFIGURATION, | ||
| 37 | + CREATE_ENTITY, | ||
| 38 | + FINISH, | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + enum StepsNameEnum { | ||
| 42 | + BASIC_INFO = '基本信息', | ||
| 43 | + IMPORT_FILE = '选择一个文件', | ||
| 44 | + COLUMN_CONFIGURATION = '选择列类型', | ||
| 45 | + CREATE_ENTITY = '创建新实体', | ||
| 46 | + FINISH = '完成', | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + const currentStep = ref<StepsEnum>(StepsEnum.BASIC_INFO); | ||
| 50 | + | ||
| 51 | + const goNextStep = () => { | ||
| 52 | + if (unref(currentStep) >= StepsEnum.FINISH) return; | ||
| 53 | + currentStep.value = unref(currentStep) + 1; | ||
| 54 | + }; | ||
| 55 | + | ||
| 56 | + const goPreviousStep = () => { | ||
| 57 | + if (unref(currentStep) <= StepsEnum.BASIC_INFO) return; | ||
| 58 | + currentStep.value = unref(currentStep) - 1; | ||
| 59 | + }; | ||
| 60 | + | ||
| 61 | + const reset = () => { | ||
| 62 | + basicInfo.value = {}; | ||
| 63 | + fileParseValue.value = { header: [], content: [] }; | ||
| 64 | + columnConfiguration.value = []; | ||
| 65 | + importResult.value = {} as unknown as ImportDeviceResponse; | ||
| 66 | + }; | ||
| 67 | + | ||
| 68 | + const handleCancel = async () => { | ||
| 69 | + if (unref(currentStep) === StepsEnum.FINISH) { | ||
| 70 | + emit('importFinally'); | ||
| 71 | + } | ||
| 72 | + currentStep.value = StepsEnum.BASIC_INFO; | ||
| 73 | + reset(); | ||
| 74 | + return true; | ||
| 75 | + }; | ||
| 76 | +</script> | ||
| 77 | + | ||
| 78 | +<template> | ||
| 79 | + <BasicModal | ||
| 80 | + title="导入设备" | ||
| 81 | + @register="register" | ||
| 82 | + width="60%" | ||
| 83 | + wrap-class-name="import-device-modal" | ||
| 84 | + :show-ok-btn="false" | ||
| 85 | + cancel-text="关闭" | ||
| 86 | + :close-func="handleCancel" | ||
| 87 | + :destroy-on-close="true" | ||
| 88 | + > | ||
| 89 | + <section class="overflow-auto"> | ||
| 90 | + <Steps direction="vertical" :current="currentStep"> | ||
| 91 | + <Steps.Step | ||
| 92 | + v-for="item in Object.keys(StepsEnum).filter((key) => isNaN(key as unknown as number))" | ||
| 93 | + :key="item" | ||
| 94 | + > | ||
| 95 | + <template #title> | ||
| 96 | + <div> | ||
| 97 | + {{ StepsNameEnum[item] }} | ||
| 98 | + </div> | ||
| 99 | + <BasicInfoForm | ||
| 100 | + v-if="StepsEnum[item] === StepsEnum.BASIC_INFO && StepsEnum[item] === currentStep" | ||
| 101 | + v-model:value="basicInfo" | ||
| 102 | + :go-next-step="goNextStep" | ||
| 103 | + :go-previous-step="goPreviousStep" | ||
| 104 | + /> | ||
| 105 | + <ImportCsv | ||
| 106 | + v-if="StepsEnum[item] === StepsEnum.IMPORT_FILE && StepsEnum[item] === currentStep" | ||
| 107 | + v-model:value="fileParseValue" | ||
| 108 | + :go-next-step="goNextStep" | ||
| 109 | + :go-previous-step="goPreviousStep" | ||
| 110 | + /> | ||
| 111 | + <ColumnType | ||
| 112 | + v-model:value="columnConfiguration" | ||
| 113 | + v-if=" | ||
| 114 | + StepsEnum[item] === StepsEnum.COLUMN_CONFIGURATION && | ||
| 115 | + StepsEnum[item] === currentStep | ||
| 116 | + " | ||
| 117 | + :file-parse-value="fileParseValue" | ||
| 118 | + :go-next-step="goNextStep" | ||
| 119 | + :go-previous-step="goPreviousStep" | ||
| 120 | + /> | ||
| 121 | + <CreateEntity | ||
| 122 | + v-model:result="importResult" | ||
| 123 | + :value="assemblyData" | ||
| 124 | + v-if="StepsEnum[item] === StepsEnum.CREATE_ENTITY && StepsEnum[item] === currentStep" | ||
| 125 | + :go-next-step="goNextStep" | ||
| 126 | + :go-previous-step="goPreviousStep" | ||
| 127 | + /> | ||
| 128 | + <CompleteResult | ||
| 129 | + v-if="StepsEnum[item] === StepsEnum.FINISH && StepsEnum[item] === currentStep" | ||
| 130 | + :value="importResult" | ||
| 131 | + /> | ||
| 132 | + </template> | ||
| 133 | + </Steps.Step> | ||
| 134 | + </Steps> | ||
| 135 | + </section> | ||
| 136 | + </BasicModal> | ||
| 137 | +</template> | ||
| 138 | + | ||
| 139 | +<style lang="less"> | ||
| 140 | + .import-device-modal { | ||
| 141 | + .ant-steps-item-title { | ||
| 142 | + width: 100%; | ||
| 143 | + } | ||
| 144 | + } | ||
| 145 | +</style> | 
| 1 | +import { ColumnFileEnum } from './config'; | ||
| 2 | + | ||
| 3 | +export interface UploadFileParseValue { | ||
| 4 | + header: string[]; | ||
| 5 | + content: Recordable[]; | ||
| 6 | +} | ||
| 7 | + | ||
| 8 | +export interface ColumnDataRecord extends Record<ColumnFileEnum, string> { | ||
| 9 | + editable?: boolean; | ||
| 10 | + oldValue: any | undefined; | ||
| 11 | + id?: string; | ||
| 12 | + name?: string; | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +export interface Options { | ||
| 16 | + label: string; | ||
| 17 | + value?: string; | ||
| 18 | + options?: Options[]; | ||
| 19 | + disabled?: boolean; | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +export type BasicInfoRecord = Record< | ||
| 23 | + 'organizationId' | 'tkDeviceProfileId' | 'deviceTypeEnum' | 'deviceTypeName', | ||
| 24 | + string | ||
| 25 | +>; | ||
| 26 | + | ||
| 27 | +export interface CreateEntityValue { | ||
| 28 | + basicInfo: BasicInfoRecord; | ||
| 29 | + fileParseValue: UploadFileParseValue; | ||
| 30 | + columnConfiguration: Record<'type', string>[]; | ||
| 31 | +} | 
| @@ -20,6 +20,8 @@ | @@ -20,6 +20,8 @@ | ||
| 20 | import { BasicForm, useForm } from '/@/components/Form'; | 20 | import { BasicForm, useForm } from '/@/components/Form'; | 
| 21 | import { customerForm } from '../../config/detail.config'; | 21 | import { customerForm } from '../../config/detail.config'; | 
| 22 | import { dispatchCustomer as dispatchCustomerApi } from '/@/api/device/deviceManager'; | 22 | import { dispatchCustomer as dispatchCustomerApi } from '/@/api/device/deviceManager'; | 
| 23 | + import { DeviceModel } from '/@/api/device/model/deviceModel'; | ||
| 24 | + import { isArray } from '/@/utils/is'; | ||
| 23 | export default defineComponent({ | 25 | export default defineComponent({ | 
| 24 | name: 'AlarmDetailModal', | 26 | name: 'AlarmDetailModal', | 
| 25 | components: { | 27 | components: { | 
| @@ -28,9 +30,10 @@ | @@ -28,9 +30,10 @@ | ||
| 28 | }, | 30 | }, | 
| 29 | emits: ['reload', 'register'], | 31 | emits: ['reload', 'register'], | 
| 30 | setup(_, { emit }) { | 32 | setup(_, { emit }) { | 
| 31 | - let record = {}; | ||
| 32 | - const [registerModal, { closeModal }] = useModalInner((data: any) => { | ||
| 33 | - const { organizationId } = data; | 33 | + let record: DeviceModel[] = []; | 
| 34 | + const [registerModal, { closeModal }] = useModalInner((data: DeviceModel | DeviceModel[]) => { | ||
| 35 | + data = isArray(data) ? data : [data as DeviceModel]; | ||
| 36 | + const { organizationId } = data.at(0) || {}; | ||
| 34 | record = data; | 37 | record = data; | 
| 35 | updateSchema([ | 38 | updateSchema([ | 
| 36 | { | 39 | { | 
| @@ -50,7 +53,11 @@ | @@ -50,7 +53,11 @@ | ||
| 50 | const dispatchCustomer = async () => { | 53 | const dispatchCustomer = async () => { | 
| 51 | await validate(); | 54 | await validate(); | 
| 52 | const { customerId } = getFieldsValue(); | 55 | const { customerId } = getFieldsValue(); | 
| 53 | - await dispatchCustomerApi({ ...record, customerId }); | 56 | + const task: Promise<any>[] = []; | 
| 57 | + for (const item of record) { | ||
| 58 | + task.push(dispatchCustomerApi({ ...item, customerId })); | ||
| 59 | + } | ||
| 60 | + await Promise.all(task); | ||
| 54 | closeModal(); | 61 | closeModal(); | 
| 55 | resetFields(); | 62 | resetFields(); | 
| 56 | emit('reload'); | 63 | emit('reload'); | 
| @@ -102,7 +102,7 @@ | @@ -102,7 +102,7 @@ | ||
| 102 | // 设备详情 | 102 | // 设备详情 | 
| 103 | const res = await getDeviceDetail(id); | 103 | const res = await getDeviceDetail(id); | 
| 104 | deviceDetail.value = res; | 104 | deviceDetail.value = res; | 
| 105 | - const { latitude, longitude, address } = res.deviceInfo; | 105 | + const { latitude, longitude, address } = res.deviceInfo || {}; | 
| 106 | if (latitude) { | 106 | if (latitude) { | 
| 107 | deviceDetailRef.value.initMap(longitude, latitude, address); | 107 | deviceDetailRef.value.initMap(longitude, latitude, address); | 
| 108 | } | 108 | } | 
| @@ -380,7 +380,7 @@ | @@ -380,7 +380,7 @@ | ||
| 380 | }; | 380 | }; | 
| 381 | // 父组件调用更新字段值的方法 | 381 | // 父组件调用更新字段值的方法 | 
| 382 | function parentSetFieldsValue(data) { | 382 | function parentSetFieldsValue(data) { | 
| 383 | - const { deviceInfo } = data; | 383 | + const { deviceInfo = {} } = data; | 
| 384 | positionState.longitude = deviceInfo.longitude; | 384 | positionState.longitude = deviceInfo.longitude; | 
| 385 | positionState.latitude = deviceInfo.latitude; | 385 | positionState.latitude = deviceInfo.latitude; | 
| 386 | positionState.address = deviceInfo.address; | 386 | positionState.address = deviceInfo.address; | 
| @@ -14,13 +14,24 @@ | @@ -14,13 +14,24 @@ | ||
| 14 | title="您确定要批量删除数据" | 14 | title="您确定要批量删除数据" | 
| 15 | ok-text="确定" | 15 | ok-text="确定" | 
| 16 | cancel-text="取消" | 16 | cancel-text="取消" | 
| 17 | - @confirm="handleDeleteOrBatchDelete(null)" | 17 | + @confirm="handleDelete()" | 
| 18 | > | 18 | > | 
| 19 | - <a-button color="error" v-if="authBtn(role)" :disabled="hasBatchDelete"> | 19 | + <a-button color="error" v-if="authBtn(role)" :disabled="!isExistOption"> | 
| 20 | 批量删除 | 20 | 批量删除 | 
| 21 | </a-button> | 21 | </a-button> | 
| 22 | </Popconfirm> | 22 | </Popconfirm> | 
| 23 | </Authority> | 23 | </Authority> | 
| 24 | + <Authority> | ||
| 25 | + <Button type="primary" @click="handleBatchImport">导入</Button> | ||
| 26 | + </Authority> | ||
| 27 | + <a-button | ||
| 28 | + v-if="authBtn(role)" | ||
| 29 | + type="primary" | ||
| 30 | + @click="handleBatchAssign" | ||
| 31 | + :disabled="!isExistOption" | ||
| 32 | + > | ||
| 33 | + 批量分配 | ||
| 34 | + </a-button> | ||
| 24 | </template> | 35 | </template> | 
| 25 | <template #img="{ record }"> | 36 | <template #img="{ record }"> | 
| 26 | <TableImg | 37 | <TableImg | 
| @@ -147,7 +158,7 @@ | @@ -147,7 +158,7 @@ | ||
| 147 | color: 'error', | 158 | color: 'error', | 
| 148 | popConfirm: { | 159 | popConfirm: { | 
| 149 | title: '是否确认删除', | 160 | title: '是否确认删除', | 
| 150 | - confirm: handleDeleteOrBatchDelete.bind(null, record), | 161 | + confirm: handleDelete.bind(null, record), | 
| 151 | }, | 162 | }, | 
| 152 | }, | 163 | }, | 
| 153 | ]" | 164 | ]" | 
| @@ -165,15 +176,22 @@ | @@ -165,15 +176,22 @@ | ||
| 165 | 176 | ||
| 166 | <DeviceModal @register="registerModal" @success="handleSuccess" @reload="handleSuccess" /> | 177 | <DeviceModal @register="registerModal" @success="handleSuccess" @reload="handleSuccess" /> | 
| 167 | <CustomerModal @register="registerCustomerModal" @reload="handleReload" /> | 178 | <CustomerModal @register="registerCustomerModal" @reload="handleReload" /> | 
| 179 | + | ||
| 180 | + <BatchImportModal @register="registerImportModal" @import-finally="handleImportFinally" /> | ||
| 168 | </PageWrapper> | 181 | </PageWrapper> | 
| 169 | </div> | 182 | </div> | 
| 170 | </template> | 183 | </template> | 
| 171 | <script lang="ts"> | 184 | <script lang="ts"> | 
| 172 | import { defineComponent, reactive, unref, nextTick, h, onUnmounted, ref } from 'vue'; | 185 | import { defineComponent, reactive, unref, nextTick, h, onUnmounted, ref } from 'vue'; | 
| 173 | - import { DeviceState, DeviceTypeEnum } from '/@/api/device/model/deviceModel'; | 186 | + import { | 
| 187 | + DeviceModel, | ||
| 188 | + DeviceRecord, | ||
| 189 | + DeviceState, | ||
| 190 | + DeviceTypeEnum, | ||
| 191 | + } from '/@/api/device/model/deviceModel'; | ||
| 174 | import { BasicTable, useTable, TableAction, TableImg } from '/@/components/Table'; | 192 | import { BasicTable, useTable, TableAction, TableImg } from '/@/components/Table'; | 
| 175 | import { columns, searchFormSchema } from './config/device.data'; | 193 | import { columns, searchFormSchema } from './config/device.data'; | 
| 176 | - import { Tag, Tooltip, Popover, Popconfirm } from 'ant-design-vue'; | 194 | + import { Tag, Tooltip, Popover, Popconfirm, Button } from 'ant-design-vue'; | 
| 177 | import { | 195 | import { | 
| 178 | deleteDevice, | 196 | deleteDevice, | 
| 179 | devicePage, | 197 | devicePage, | 
| @@ -190,15 +208,16 @@ | @@ -190,15 +208,16 @@ | ||
| 190 | import { useDrawer } from '/@/components/Drawer'; | 208 | import { useDrawer } from '/@/components/Drawer'; | 
| 191 | import DeviceDetailDrawer from './cpns/modal/DeviceDetailDrawer.vue'; | 209 | import DeviceDetailDrawer from './cpns/modal/DeviceDetailDrawer.vue'; | 
| 192 | import CustomerModal from './cpns/modal/CustomerModal.vue'; | 210 | import CustomerModal from './cpns/modal/CustomerModal.vue'; | 
| 211 | + import BatchImportModal from './cpns/modal/BatchImportModal/index.vue'; | ||
| 193 | import { useMessage } from '/@/hooks/web/useMessage'; | 212 | import { useMessage } from '/@/hooks/web/useMessage'; | 
| 194 | import { USER_INFO_KEY } from '/@/enums/cacheEnum'; | 213 | import { USER_INFO_KEY } from '/@/enums/cacheEnum'; | 
| 195 | import { getAuthCache } from '/@/utils/auth'; | 214 | import { getAuthCache } from '/@/utils/auth'; | 
| 196 | import { authBtn } from '/@/enums/roleEnum'; | 215 | import { authBtn } from '/@/enums/roleEnum'; | 
| 197 | - import { useBatchDelete } from '/@/hooks/web/useBatchDelete'; | ||
| 198 | import { useCopyToClipboard } from '/@/hooks/web/useCopyToClipboard'; | 216 | import { useCopyToClipboard } from '/@/hooks/web/useCopyToClipboard'; | 
| 199 | import { QuestionCircleOutlined } from '@ant-design/icons-vue'; | 217 | import { QuestionCircleOutlined } from '@ant-design/icons-vue'; | 
| 200 | import { Authority } from '/@/components/Authority'; | 218 | import { Authority } from '/@/components/Authority'; | 
| 201 | import { useRouter } from 'vue-router'; | 219 | import { useRouter } from 'vue-router'; | 
| 220 | + import { useBatchOperation } from '/@/utils/useBatchOperation'; | ||
| 202 | 221 | ||
| 203 | export default defineComponent({ | 222 | export default defineComponent({ | 
| 204 | name: 'DeviceManagement', | 223 | name: 'DeviceManagement', | 
| @@ -217,6 +236,8 @@ | @@ -217,6 +236,8 @@ | ||
| 217 | Popover, | 236 | Popover, | 
| 218 | Authority, | 237 | Authority, | 
| 219 | Popconfirm, | 238 | Popconfirm, | 
| 239 | + BatchImportModal, | ||
| 240 | + Button, | ||
| 220 | }, | 241 | }, | 
| 221 | setup(_) { | 242 | setup(_) { | 
| 222 | const { createMessage } = useMessage(); | 243 | const { createMessage } = useMessage(); | 
| @@ -230,10 +251,21 @@ | @@ -230,10 +251,21 @@ | ||
| 230 | const [registerDetailDrawer, { openDrawer }] = useDrawer(); | 251 | const [registerDetailDrawer, { openDrawer }] = useDrawer(); | 
| 231 | const [registerTbDetailDrawer, { openDrawer: openTbDeviceDrawer }] = useDrawer(); | 252 | const [registerTbDetailDrawer, { openDrawer: openTbDeviceDrawer }] = useDrawer(); | 
| 232 | const [registerGatewayDetailDrawer, { openDrawer: openGatewayDetailDrawer }] = useDrawer(); | 253 | const [registerGatewayDetailDrawer, { openDrawer: openGatewayDetailDrawer }] = useDrawer(); | 
| 254 | + const [registerImportModal, { openModal: openImportModal }] = useModal(); | ||
| 233 | 255 | ||
| 234 | const [ | 256 | const [ | 
| 235 | registerTable, | 257 | registerTable, | 
| 236 | - { reload, setSelectedRowKeys, setProps, setTableData, getForm, setPagination }, | 258 | + { | 
| 259 | + reload, | ||
| 260 | + setLoading, | ||
| 261 | + setSelectedRowKeys, | ||
| 262 | + setTableData, | ||
| 263 | + getForm, | ||
| 264 | + setPagination, | ||
| 265 | + getSelectRowKeys, | ||
| 266 | + getSelectRows, | ||
| 267 | + getRowSelection, | ||
| 268 | + }, | ||
| 237 | ] = useTable({ | 269 | ] = useTable({ | 
| 238 | title: '设备列表', | 270 | title: '设备列表', | 
| 239 | api: devicePage, | 271 | api: devicePage, | 
| @@ -241,7 +273,6 @@ | @@ -241,7 +273,6 @@ | ||
| 241 | columns, | 273 | columns, | 
| 242 | beforeFetch: (params) => { | 274 | beforeFetch: (params) => { | 
| 243 | const { deviceProfileId } = params; | 275 | const { deviceProfileId } = params; | 
| 244 | - console.log(deviceProfileId); | ||
| 245 | const obj = { | 276 | const obj = { | 
| 246 | ...params, | 277 | ...params, | 
| 247 | ...{ | 278 | ...{ | 
| @@ -272,20 +303,15 @@ | @@ -272,20 +303,15 @@ | ||
| 272 | slots: { customRender: 'action' }, | 303 | slots: { customRender: 'action' }, | 
| 273 | fixed: 'right', | 304 | fixed: 'right', | 
| 274 | }, | 305 | }, | 
| 306 | + rowSelection: { | ||
| 307 | + type: 'checkbox', | ||
| 308 | + getCheckboxProps: (record: DeviceModel) => { | ||
| 309 | + return { disabled: !!record.customerId }; | ||
| 310 | + }, | ||
| 311 | + }, | ||
| 275 | }); | 312 | }); | 
| 276 | - const { hasBatchDelete, handleDeleteOrBatchDelete, selectionOptions, resetSelectedRowKeys } = | ||
| 277 | - useBatchDelete(deleteDevice, handleSuccess, setProps); | ||
| 278 | - selectionOptions.rowSelection.getCheckboxProps = (record: Recordable) => { | ||
| 279 | - // Demo:status为1的选择框禁用 | ||
| 280 | - if (record.customerId) { | ||
| 281 | - return { disabled: true }; | ||
| 282 | - } else { | ||
| 283 | - return { disabled: false }; | ||
| 284 | - } | ||
| 285 | - }; | ||
| 286 | - nextTick(() => { | ||
| 287 | - setProps(selectionOptions); | ||
| 288 | - }); | 313 | + | 
| 314 | + const { isExistOption } = useBatchOperation(getRowSelection, setSelectedRowKeys); | ||
| 289 | 315 | ||
| 290 | function getParams(keyword) { | 316 | function getParams(keyword) { | 
| 291 | const reg = new RegExp('(^|&)' + keyword + '=([^&]*)(&|$)', 'i'); | 317 | const reg = new RegExp('(^|&)' + keyword + '=([^&]*)(&|$)', 'i'); | 
| @@ -349,7 +375,6 @@ | @@ -349,7 +375,6 @@ | ||
| 349 | } | 375 | } | 
| 350 | function handleReload() { | 376 | function handleReload() { | 
| 351 | setSelectedRowKeys([]); | 377 | setSelectedRowKeys([]); | 
| 352 | - resetSelectedRowKeys(); | ||
| 353 | handleSuccess(); | 378 | handleSuccess(); | 
| 354 | } | 379 | } | 
| 355 | // 取消分配客户 | 380 | // 取消分配客户 | 
| @@ -419,6 +444,56 @@ | @@ -419,6 +444,56 @@ | ||
| 419 | }); | 444 | }); | 
| 420 | }; | 445 | }; | 
| 421 | 446 | ||
| 447 | + const handleCheckHasDiffenterOrg = (options: DeviceModel[]) => { | ||
| 448 | + let orgId: string | undefined; | ||
| 449 | + let flag = false; | ||
| 450 | + for (const item of options) { | ||
| 451 | + const _orgId = item.organizationId; | ||
| 452 | + if (!orgId) orgId = _orgId; | ||
| 453 | + if (orgId !== _orgId) { | ||
| 454 | + flag = true; | ||
| 455 | + break; | ||
| 456 | + } | ||
| 457 | + } | ||
| 458 | + return flag; | ||
| 459 | + }; | ||
| 460 | + | ||
| 461 | + const handleBatchAssign = () => { | ||
| 462 | + const options = getSelectRows(); | ||
| 463 | + if (handleCheckHasDiffenterOrg(options as DeviceModel[])) { | ||
| 464 | + createMessage.error('当前选中项中存在不同所属组织的设备!'); | ||
| 465 | + return; | ||
| 466 | + } | ||
| 467 | + openCustomerModal(true, options); | ||
| 468 | + }; | ||
| 469 | + | ||
| 470 | + const handleDelete = async (record?: DeviceRecord) => { | ||
| 471 | + let ids: string[] = []; | ||
| 472 | + if (record) { | ||
| 473 | + ids.push(record.id); | ||
| 474 | + } else { | ||
| 475 | + ids = getSelectRowKeys(); | ||
| 476 | + } | ||
| 477 | + try { | ||
| 478 | + setLoading(true); | ||
| 479 | + await deleteDevice(ids); | ||
| 480 | + createMessage.success('删除成功'); | ||
| 481 | + handleReload(); | ||
| 482 | + } catch (error) { | ||
| 483 | + createMessage.error('删除失败'); | ||
| 484 | + } finally { | ||
| 485 | + setLoading(false); | ||
| 486 | + } | ||
| 487 | + }; | ||
| 488 | + | ||
| 489 | + const handleBatchImport = () => { | ||
| 490 | + openImportModal(true); | ||
| 491 | + }; | ||
| 492 | + | ||
| 493 | + const handleImportFinally = () => { | ||
| 494 | + reload(); | ||
| 495 | + }; | ||
| 496 | + | ||
| 422 | return { | 497 | return { | 
| 423 | registerTable, | 498 | registerTable, | 
| 424 | handleCreate, | 499 | handleCreate, | 
| @@ -439,14 +514,20 @@ | @@ -439,14 +514,20 @@ | ||
| 439 | authBtn, | 514 | authBtn, | 
| 440 | role, | 515 | role, | 
| 441 | copySN, | 516 | copySN, | 
| 442 | - hasBatchDelete, | ||
| 443 | - handleDeleteOrBatchDelete, | 517 | + isExistOption, | 
| 518 | + handleDelete, | ||
| 519 | + // hasBatchDelete, | ||
| 520 | + // handleDeleteOrBatchDelete, | ||
| 444 | handleReload, | 521 | handleReload, | 
| 445 | registerTbDetailDrawer, | 522 | registerTbDetailDrawer, | 
| 446 | handleOpenTbDeviceDetail, | 523 | handleOpenTbDeviceDetail, | 
| 447 | handleOpenGatewayDetail, | 524 | handleOpenGatewayDetail, | 
| 448 | registerGatewayDetailDrawer, | 525 | registerGatewayDetailDrawer, | 
| 449 | handleUpAndDownRecord, | 526 | handleUpAndDownRecord, | 
| 527 | + handleBatchAssign, | ||
| 528 | + registerImportModal, | ||
| 529 | + handleBatchImport, | ||
| 530 | + handleImportFinally, | ||
| 450 | }; | 531 | }; | 
| 451 | }, | 532 | }, | 
| 452 | }); | 533 | }); | 
| @@ -260,6 +260,8 @@ | @@ -260,6 +260,8 @@ | ||
| 260 | } | 260 | } | 
| 261 | }; | 261 | }; | 
| 262 | const setClientProperties = (record: Recordable) => { | 262 | const setClientProperties = (record: Recordable) => { | 
| 263 | + const type = Reflect.get(record, 'type'); | ||
| 264 | + if (type === 'org.thingsboard.rule.engine.rabbitmq.TbRabbitMqNode') return; | ||
| 263 | const configuration = Reflect.get(record, 'configuration'); | 265 | const configuration = Reflect.get(record, 'configuration'); | 
| 264 | const clientProperties = Reflect.get(configuration, 'clientProperties'); | 266 | const clientProperties = Reflect.get(configuration, 'clientProperties'); | 
| 265 | !clientProperties && record.configuration && (record.configuration.clientProperties = {}); | 267 | !clientProperties && record.configuration && (record.configuration.clientProperties = {}); |