Showing
23 changed files
with
1127 additions
and
32 deletions
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 | +} | ... | ... | 
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 | 20 | import { BasicForm, useForm } from '/@/components/Form'; | 
| 21 | 21 | import { customerForm } from '../../config/detail.config'; | 
| 22 | 22 | import { dispatchCustomer as dispatchCustomerApi } from '/@/api/device/deviceManager'; | 
| 23 | + import { DeviceModel } from '/@/api/device/model/deviceModel'; | |
| 24 | + import { isArray } from '/@/utils/is'; | |
| 23 | 25 | export default defineComponent({ | 
| 24 | 26 | name: 'AlarmDetailModal', | 
| 25 | 27 | components: { | 
| ... | ... | @@ -28,9 +30,10 @@ | 
| 28 | 30 | }, | 
| 29 | 31 | emits: ['reload', 'register'], | 
| 30 | 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 | 37 | record = data; | 
| 35 | 38 | updateSchema([ | 
| 36 | 39 | { | 
| ... | ... | @@ -50,7 +53,11 @@ | 
| 50 | 53 | const dispatchCustomer = async () => { | 
| 51 | 54 | await validate(); | 
| 52 | 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 | 61 | closeModal(); | 
| 55 | 62 | resetFields(); | 
| 56 | 63 | emit('reload'); | ... | ... | 
| ... | ... | @@ -102,7 +102,7 @@ | 
| 102 | 102 | // 设备详情 | 
| 103 | 103 | const res = await getDeviceDetail(id); | 
| 104 | 104 | deviceDetail.value = res; | 
| 105 | - const { latitude, longitude, address } = res.deviceInfo; | |
| 105 | + const { latitude, longitude, address } = res.deviceInfo || {}; | |
| 106 | 106 | if (latitude) { | 
| 107 | 107 | deviceDetailRef.value.initMap(longitude, latitude, address); | 
| 108 | 108 | } | ... | ... | 
| ... | ... | @@ -380,7 +380,7 @@ | 
| 380 | 380 | }; | 
| 381 | 381 | // 父组件调用更新字段值的方法 | 
| 382 | 382 | function parentSetFieldsValue(data) { | 
| 383 | - const { deviceInfo } = data; | |
| 383 | + const { deviceInfo = {} } = data; | |
| 384 | 384 | positionState.longitude = deviceInfo.longitude; | 
| 385 | 385 | positionState.latitude = deviceInfo.latitude; | 
| 386 | 386 | positionState.address = deviceInfo.address; | ... | ... | 
| ... | ... | @@ -14,13 +14,24 @@ | 
| 14 | 14 | title="您确定要批量删除数据" | 
| 15 | 15 | ok-text="确定" | 
| 16 | 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 | 21 | </a-button> | 
| 22 | 22 | </Popconfirm> | 
| 23 | 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 | 35 | </template> | 
| 25 | 36 | <template #img="{ record }"> | 
| 26 | 37 | <TableImg | 
| ... | ... | @@ -147,7 +158,7 @@ | 
| 147 | 158 | color: 'error', | 
| 148 | 159 | popConfirm: { | 
| 149 | 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 | 176 | |
| 166 | 177 | <DeviceModal @register="registerModal" @success="handleSuccess" @reload="handleSuccess" /> | 
| 167 | 178 | <CustomerModal @register="registerCustomerModal" @reload="handleReload" /> | 
| 179 | + | |
| 180 | + <BatchImportModal @register="registerImportModal" @import-finally="handleImportFinally" /> | |
| 168 | 181 | </PageWrapper> | 
| 169 | 182 | </div> | 
| 170 | 183 | </template> | 
| 171 | 184 | <script lang="ts"> | 
| 172 | 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 | 192 | import { BasicTable, useTable, TableAction, TableImg } from '/@/components/Table'; | 
| 175 | 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 | 195 | import { | 
| 178 | 196 | deleteDevice, | 
| 179 | 197 | devicePage, | 
| ... | ... | @@ -190,15 +208,16 @@ | 
| 190 | 208 | import { useDrawer } from '/@/components/Drawer'; | 
| 191 | 209 | import DeviceDetailDrawer from './cpns/modal/DeviceDetailDrawer.vue'; | 
| 192 | 210 | import CustomerModal from './cpns/modal/CustomerModal.vue'; | 
| 211 | + import BatchImportModal from './cpns/modal/BatchImportModal/index.vue'; | |
| 193 | 212 | import { useMessage } from '/@/hooks/web/useMessage'; | 
| 194 | 213 | import { USER_INFO_KEY } from '/@/enums/cacheEnum'; | 
| 195 | 214 | import { getAuthCache } from '/@/utils/auth'; | 
| 196 | 215 | import { authBtn } from '/@/enums/roleEnum'; | 
| 197 | - import { useBatchDelete } from '/@/hooks/web/useBatchDelete'; | |
| 198 | 216 | import { useCopyToClipboard } from '/@/hooks/web/useCopyToClipboard'; | 
| 199 | 217 | import { QuestionCircleOutlined } from '@ant-design/icons-vue'; | 
| 200 | 218 | import { Authority } from '/@/components/Authority'; | 
| 201 | 219 | import { useRouter } from 'vue-router'; | 
| 220 | + import { useBatchOperation } from '/@/utils/useBatchOperation'; | |
| 202 | 221 | |
| 203 | 222 | export default defineComponent({ | 
| 204 | 223 | name: 'DeviceManagement', | 
| ... | ... | @@ -217,6 +236,8 @@ | 
| 217 | 236 | Popover, | 
| 218 | 237 | Authority, | 
| 219 | 238 | Popconfirm, | 
| 239 | + BatchImportModal, | |
| 240 | + Button, | |
| 220 | 241 | }, | 
| 221 | 242 | setup(_) { | 
| 222 | 243 | const { createMessage } = useMessage(); | 
| ... | ... | @@ -230,10 +251,21 @@ | 
| 230 | 251 | const [registerDetailDrawer, { openDrawer }] = useDrawer(); | 
| 231 | 252 | const [registerTbDetailDrawer, { openDrawer: openTbDeviceDrawer }] = useDrawer(); | 
| 232 | 253 | const [registerGatewayDetailDrawer, { openDrawer: openGatewayDetailDrawer }] = useDrawer(); | 
| 254 | + const [registerImportModal, { openModal: openImportModal }] = useModal(); | |
| 233 | 255 | |
| 234 | 256 | const [ | 
| 235 | 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 | 269 | ] = useTable({ | 
| 238 | 270 | title: '设备列表', | 
| 239 | 271 | api: devicePage, | 
| ... | ... | @@ -241,7 +273,6 @@ | 
| 241 | 273 | columns, | 
| 242 | 274 | beforeFetch: (params) => { | 
| 243 | 275 | const { deviceProfileId } = params; | 
| 244 | - console.log(deviceProfileId); | |
| 245 | 276 | const obj = { | 
| 246 | 277 | ...params, | 
| 247 | 278 | ...{ | 
| ... | ... | @@ -272,20 +303,15 @@ | 
| 272 | 303 | slots: { customRender: 'action' }, | 
| 273 | 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 | 316 | function getParams(keyword) { | 
| 291 | 317 | const reg = new RegExp('(^|&)' + keyword + '=([^&]*)(&|$)', 'i'); | 
| ... | ... | @@ -349,7 +375,6 @@ | 
| 349 | 375 | } | 
| 350 | 376 | function handleReload() { | 
| 351 | 377 | setSelectedRowKeys([]); | 
| 352 | - resetSelectedRowKeys(); | |
| 353 | 378 | handleSuccess(); | 
| 354 | 379 | } | 
| 355 | 380 | // 取消分配客户 | 
| ... | ... | @@ -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 | 497 | return { | 
| 423 | 498 | registerTable, | 
| 424 | 499 | handleCreate, | 
| ... | ... | @@ -439,14 +514,20 @@ | 
| 439 | 514 | authBtn, | 
| 440 | 515 | role, | 
| 441 | 516 | copySN, | 
| 442 | - hasBatchDelete, | |
| 443 | - handleDeleteOrBatchDelete, | |
| 517 | + isExistOption, | |
| 518 | + handleDelete, | |
| 519 | + // hasBatchDelete, | |
| 520 | + // handleDeleteOrBatchDelete, | |
| 444 | 521 | handleReload, | 
| 445 | 522 | registerTbDetailDrawer, | 
| 446 | 523 | handleOpenTbDeviceDetail, | 
| 447 | 524 | handleOpenGatewayDetail, | 
| 448 | 525 | registerGatewayDetailDrawer, | 
| 449 | 526 | handleUpAndDownRecord, | 
| 527 | + handleBatchAssign, | |
| 528 | + registerImportModal, | |
| 529 | + handleBatchImport, | |
| 530 | + handleImportFinally, | |
| 450 | 531 | }; | 
| 451 | 532 | }, | 
| 452 | 533 | }); | ... | ... | 
| ... | ... | @@ -260,6 +260,8 @@ | 
| 260 | 260 | } | 
| 261 | 261 | }; | 
| 262 | 262 | const setClientProperties = (record: Recordable) => { | 
| 263 | + const type = Reflect.get(record, 'type'); | |
| 264 | + if (type === 'org.thingsboard.rule.engine.rabbitmq.TbRabbitMqNode') return; | |
| 263 | 265 | const configuration = Reflect.get(record, 'configuration'); | 
| 264 | 266 | const clientProperties = Reflect.get(configuration, 'clientProperties'); | 
| 265 | 267 | !clientProperties && record.configuration && (record.configuration.clientProperties = {}); | ... | ... |