Commit 3661daeaebb70be519f8960bf0f0a923cafbba5d
Merge branch 'ww' into 'main'
feat: 新增设备批量导入功能 See merge request yunteng/thingskit-front!507
Showing
17 changed files
with
1004 additions
and
4 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 | +} | ... | ... |
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 | +} | ... | ... |
... | ... | @@ -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; | ... | ... |
... | ... | @@ -21,6 +21,9 @@ |
21 | 21 | </a-button> |
22 | 22 | </Popconfirm> |
23 | 23 | </Authority> |
24 | + <Authority> | |
25 | + <Button type="primary" @click="handleBatchImport">导入</Button> | |
26 | + </Authority> | |
24 | 27 | <a-button |
25 | 28 | v-if="authBtn(role)" |
26 | 29 | type="primary" |
... | ... | @@ -173,6 +176,8 @@ |
173 | 176 | |
174 | 177 | <DeviceModal @register="registerModal" @success="handleSuccess" @reload="handleSuccess" /> |
175 | 178 | <CustomerModal @register="registerCustomerModal" @reload="handleReload" /> |
179 | + | |
180 | + <BatchImportModal @register="registerImportModal" @import-finally="handleImportFinally" /> | |
176 | 181 | </PageWrapper> |
177 | 182 | </div> |
178 | 183 | </template> |
... | ... | @@ -186,7 +191,7 @@ |
186 | 191 | } from '/@/api/device/model/deviceModel'; |
187 | 192 | import { BasicTable, useTable, TableAction, TableImg } from '/@/components/Table'; |
188 | 193 | import { columns, searchFormSchema } from './config/device.data'; |
189 | - import { Tag, Tooltip, Popover, Popconfirm } from 'ant-design-vue'; | |
194 | + import { Tag, Tooltip, Popover, Popconfirm, Button } from 'ant-design-vue'; | |
190 | 195 | import { |
191 | 196 | deleteDevice, |
192 | 197 | devicePage, |
... | ... | @@ -203,6 +208,7 @@ |
203 | 208 | import { useDrawer } from '/@/components/Drawer'; |
204 | 209 | import DeviceDetailDrawer from './cpns/modal/DeviceDetailDrawer.vue'; |
205 | 210 | import CustomerModal from './cpns/modal/CustomerModal.vue'; |
211 | + import BatchImportModal from './cpns/modal/BatchImportModal/index.vue'; | |
206 | 212 | import { useMessage } from '/@/hooks/web/useMessage'; |
207 | 213 | import { USER_INFO_KEY } from '/@/enums/cacheEnum'; |
208 | 214 | import { getAuthCache } from '/@/utils/auth'; |
... | ... | @@ -230,6 +236,8 @@ |
230 | 236 | Popover, |
231 | 237 | Authority, |
232 | 238 | Popconfirm, |
239 | + BatchImportModal, | |
240 | + Button, | |
233 | 241 | }, |
234 | 242 | setup(_) { |
235 | 243 | const { createMessage } = useMessage(); |
... | ... | @@ -243,6 +251,7 @@ |
243 | 251 | const [registerDetailDrawer, { openDrawer }] = useDrawer(); |
244 | 252 | const [registerTbDetailDrawer, { openDrawer: openTbDeviceDrawer }] = useDrawer(); |
245 | 253 | const [registerGatewayDetailDrawer, { openDrawer: openGatewayDetailDrawer }] = useDrawer(); |
254 | + const [registerImportModal, { openModal: openImportModal }] = useModal(); | |
246 | 255 | |
247 | 256 | const [ |
248 | 257 | registerTable, |
... | ... | @@ -451,7 +460,7 @@ |
451 | 460 | |
452 | 461 | const handleBatchAssign = () => { |
453 | 462 | const options = getSelectRows(); |
454 | - if (handleCheckHasDiffenterOrg(options)) { | |
463 | + if (handleCheckHasDiffenterOrg(options as DeviceModel[])) { | |
455 | 464 | createMessage.error('当前选中项中存在不同所属组织的设备!'); |
456 | 465 | return; |
457 | 466 | } |
... | ... | @@ -477,6 +486,14 @@ |
477 | 486 | } |
478 | 487 | }; |
479 | 488 | |
489 | + const handleBatchImport = () => { | |
490 | + openImportModal(true); | |
491 | + }; | |
492 | + | |
493 | + const handleImportFinally = () => { | |
494 | + reload(); | |
495 | + }; | |
496 | + | |
480 | 497 | return { |
481 | 498 | registerTable, |
482 | 499 | handleCreate, |
... | ... | @@ -508,6 +525,9 @@ |
508 | 525 | registerGatewayDetailDrawer, |
509 | 526 | handleUpAndDownRecord, |
510 | 527 | handleBatchAssign, |
528 | + registerImportModal, | |
529 | + handleBatchImport, | |
530 | + handleImportFinally, | |
511 | 531 | }; |
512 | 532 | }, |
513 | 533 | }); | ... | ... |