Commit 982e25e2834868a78b79a8fb868de95c223b8bdf

Authored by ww
1 parent 1ab5eb49

feat: 设备列表新增设备批量导入

  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 +};
... ...
  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 +<script lang="ts" setup>
  2 + import { Card } from 'ant-design-vue';
  3 +</script>
  4 +<template>
  5 + <Card class="!my-4 border border-dashed border-gray-100 border-2 w-full h-full" :bordered="false">
  6 + <slot></slot>
  7 + </Card>
  8 +</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 +export const DEVICE_NAME_INDEX = 0;
  2 +export const DEVICE_TYPE_INDEX = 1;
... ...
  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 +export const basicProps = {
  2 + goPreviousStep: {
  3 + required: true,
  4 + type: Function as PropType<Fn>,
  5 + },
  6 + goNextStep: {
  7 + required: true,
  8 + type: Function as PropType<Fn>,
  9 + },
  10 +};
... ...
  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 +}
... ...
... ... @@ -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 });
... ...