diff --git a/src/api/template.ts b/src/api/template.ts new file mode 100644 index 00000000..79be9dcb --- /dev/null +++ b/src/api/template.ts @@ -0,0 +1,27 @@ +import http from '@/utils/http' +import type { AxiosResponse } from 'axios' +import type { RawTemplateData, TemplateFormData } from '@/types/config' + +export const queryTemplateList = async (): Promise> => { + const { data }: AxiosResponse<{ error: number; templates: Array }> = await http.get('/template') + return Promise.resolve(data?.templates || []) +} + +// without name,delete all +export const deleteTemplate = async (templateName?: string): Promise => { + return http.delete('/template', { + params: { name: templateName }, + }) +} + +export const createTemplate = async (data: TemplateFormData): Promise => { + return http.post('/template', data) +} + +export const updateTemplate = async (data: TemplateFormData): Promise => { + return http.post('/template', data) +} + +export const getTemplateDetailByName = async (templateName: string): Promise => { + return http.get('/template', { params: { name: templateName } }) +} diff --git a/src/components/SideNav.vue b/src/components/SideNav.vue index 346c307a..472ef6fb 100644 --- a/src/components/SideNav.vue +++ b/src/components/SideNav.vue @@ -96,6 +96,10 @@ const navList = computed(() => { to: '/configuration/north-driver', label: 'config.northAppSetup', }, + { + to: '/configuration/template', + label: 'config.templateManagement', + }, { to: '/configuration/plugin', label: 'config.plugin', diff --git a/src/composables/config/useTemplateDialog.ts b/src/composables/config/useTemplateDialog.ts new file mode 100644 index 00000000..3bc28acc --- /dev/null +++ b/src/composables/config/useTemplateDialog.ts @@ -0,0 +1,106 @@ +import { ref, computed, watch, nextTick } from 'vue' +import type { Ref } from 'vue' +import { useI18n } from 'vue-i18n' +import type { TemplateFormData } from '@/types/config' +import { createTemplate, updateTemplate } from '@/api/template' +import { EmqxMessage } from '@emqx/emqx-ui' +import { cloneDeep } from 'lodash' + +export const useTemplateForm = () => { + const createTemplateForm = (): TemplateFormData => ({ + name: '', + plugin: '', + groups: [], + }) + return { + createTemplateForm, + } +} + +interface TemplateProps { + modelValue: boolean + isEdit?: boolean + isImport?: boolean + templateData: TemplateFormData +} +export default (props: TemplateProps) => { + const { t } = useI18n() + + const { createTemplateForm } = useTemplateForm() + + const formRef = ref() + const templateForm: Ref = ref(createTemplateForm()) + const isSubmitting = ref(false) + + const rules = { + name: [{ required: true, message: t('config.nameRequired') }], + plugin: [{ required: true, message: t('config.pluginRequired') }], + } + + const dialogTitle = computed(() => { + let title = '' + if (props.isImport) { + title = t('template.importTemplate') + } else { + title = !props.isEdit ? t('template.addTemplate') : t('template.editTemplate') + } + return title + }) + + const initForm = async () => { + formRef.value.resetField() + templateForm.value = createTemplateForm() + } + + const submitData = async () => { + try { + await formRef.value.validate() + isSubmitting.value = true + + const data = templateForm.value + if (!props.isEdit || props.isImport) { + await createTemplate(data) + + const message = !props.isImport ? t('common.createSuccess') : t('common.importSuccess') + EmqxMessage.success(message) + } else { + // TODO: edit + await updateTemplate(data) + EmqxMessage.success(t('common.updateSuccess')) + } + return Promise.resolve() + } catch (error) { + return Promise.reject() + } finally { + isSubmitting.value = false + } + } + + const initTemplateData = () => { + if (props.isImport) { + templateForm.value = cloneDeep(props.templateData) + } + } + + watch( + () => props.templateData.name, + (newV) => { + if (newV) { + nextTick(() => { + initTemplateData() + }) + } + }, + { immediate: true }, + ) + return { + createTemplateForm, + formRef, + templateForm, + dialogTitle, + isSubmitting, + rules, + initForm, + submitData, + } +} diff --git a/src/composables/config/useTemplateList.ts b/src/composables/config/useTemplateList.ts new file mode 100644 index 00000000..ecd32682 --- /dev/null +++ b/src/composables/config/useTemplateList.ts @@ -0,0 +1,122 @@ +import { ref } from 'vue' +import type { Ref } from 'vue' +import { useI18n } from 'vue-i18n' +import type { RawTemplateData, TemplateFormData } from '@/types/config' +import { queryTemplateList, deleteTemplate, getTemplateDetailByName } from '@/api/template' +import { EmqxMessage } from '@emqx/emqx-ui' +import { MessageBoxConfirm } from '@/utils/element' +import { useTemplateForm } from '@/composables/config/useTemplateDialog' +import { useDownload } from '@/composables/useDownload' +import { dataType, isJSONData } from '@/utils/utils' + +export default () => { + const { t } = useI18n() + const { createTemplateForm } = useTemplateForm() + + const templateList: Ref> = ref([]) + const isListLoading = ref(false) + const templateDialogVisible = ref(false) + const isEditTemplate = ref(false) + const isImportTemplate = ref(false) + const editTemplateData: Ref = ref(createTemplateForm()) + + const getTemplateList = async () => { + try { + isListLoading.value = true + templateList.value = await queryTemplateList() + } finally { + isListLoading.value = false + } + } + + const showTemplateDialog = () => { + templateDialogVisible.value = true + } + + const goGroupPage = (rowData: RawTemplateData) => { + // TODO + console.log('row', rowData) + } + const editTemplate = (rowData: RawTemplateData) => { + // TODO + isEditTemplate.value = true + console.log('row', rowData) + } + const removeTemplate = async (rowData: RawTemplateData) => { + try { + await MessageBoxConfirm() // Phase 2 support: t('template.deleteTemplateTip') + const { name } = rowData + await deleteTemplate(name) + EmqxMessage.success(t('common.operateSuccessfully')) + getTemplateList() + } catch (error) { + console.error(error) + } + } + + const { downloadFile, readTextFile } = useDownload() + const exportTemplate = async (rowData: RawTemplateData) => { + try { + const { name } = rowData + const { data } = await getTemplateDetailByName(name) + const dataJSON = JSON.stringify(data, null, 2) + const blobData = new Blob([dataJSON]) + downloadFile({ 'content-type': 'application/octet-stream', 'content-disposition': `filename=${name}` }, blobData) + } catch (error) { + console.error(error) + } + } + + // import file + const importFile = async (file: File) => { + if (dataType(file) !== 'file') { + EmqxMessage.error(t('common.notJSONData')) + } + + isImportTemplate.value = true + const fileData: unknown = await readTextFile(file) + + if (isJSONData(String(fileData))) { + const jsonData = JSON.parse(String(fileData)) || createTemplateForm() + + const { name, plugin } = jsonData + if (!name) { + EmqxMessage.error(t('template.missingNameInFile')) + } + if (!plugin) { + EmqxMessage.error(t('template.missingPluginInFile')) + } + if (name && plugin) { + editTemplateData.value = jsonData + templateDialogVisible.value = true + } + } + + // Capture uploader action + return Promise.reject() + } + + const cancelOperateTemplate = () => { + isEditTemplate.value = false + isImportTemplate.value = false + editTemplateData.value = createTemplateForm() + } + + getTemplateList() + return { + templateList, + isListLoading, + getTemplateList, + templateDialogVisible, + showTemplateDialog, + goGroupPage, + removeTemplate, + editTemplate, + exportTemplate, + importFile, + isImportTemplate, + editTemplateData, + isEditTemplate, + cancelOperateTemplate, + } +} diff --git a/src/composables/useDownload.ts b/src/composables/useDownload.ts index 727ae812..0de46a13 100644 --- a/src/composables/useDownload.ts +++ b/src/composables/useDownload.ts @@ -1,4 +1,10 @@ +import { useI18n } from 'vue-i18n' +import { dataType } from '@/utils/utils' +import { ElMessage } from 'element-plus' + export const useDownload = () => { + const { t } = useI18n() + // must take the `responseType: 'blob'` when api request,such as `downloadLogs` api in `@/api/admin` const downloadFile = (responseHeaders: any, blobData: Blob, fileNameKey?: string) => { if (!responseHeaders) return @@ -17,7 +23,7 @@ export const useDownload = () => { // download if ('download' in document.createElement('a')) { // not IE - const url = window.URL.createObjectURL(new Blob([blobData])) + const url = window.URL.createObjectURL(new Blob([blobData], { type: 'application/json' })) const link = document.createElement('a') link.href = url link.setAttribute('download', name) @@ -31,7 +37,30 @@ export const useDownload = () => { } } + const readTextFile = (file: File) => { + return new Promise((resolve, reject) => { + if (dataType(file) !== 'file') { + ElMessage.error(t('common.isNotFile')) + reject(t('common.isNotFile')) + } else { + let fileContent: string | ArrayBuffer | null = '' + const reader = new FileReader() + reader.readAsText(file, 'utf-8') + reader.onload = () => { + fileContent = reader?.result || '' + resolve(fileContent) + } + reader.onerror = () => { + ElMessage.error(t('common.readFileError')) + console.error(reader.error) + reject(reader.error) + } + } + }) + } + return { downloadFile, + readTextFile, } } diff --git a/src/i18n/common.ts b/src/i18n/common.ts index e2208fce..5dc010f8 100644 --- a/src/i18n/common.ts +++ b/src/i18n/common.ts @@ -133,12 +133,16 @@ export default { }, submitSuccess: { zh: '提交成功', - en: 'Submit success!', + en: 'Submit success', }, createSuccess: { zh: '创建成功', en: 'Create success', }, + updateSuccess: { + zh: '更新成功', + en: 'Update success', + }, operateConfirm: { zh: '操作确认', en: 'Operation Confirmation', @@ -375,4 +379,20 @@ export default { zh: '卡片', en: 'Card', }, + importSuccess: { + zh: '导入成功', + en: 'Import success', + }, + isNotFile: { + zh: '不支持非文件类型', + en: 'Non-file types are not supported', + }, + notJSONData: { + zh: '非 JSON 数据', + en: 'Non-JSON data', + }, + jsonFormatError: { + zh: '数据格式发生错误,请检查数据', + en: 'Data format error, please check the data', + }, } diff --git a/src/i18n/config.ts b/src/i18n/config.ts index bfbcb4f8..32e480b1 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -155,6 +155,10 @@ export default { zh: '南向设备', en: 'South device', }, + templateManagement: { + zh: '模板管理', + en: 'Template', + }, plugin: { zh: '插件', en: 'Plugin', @@ -469,4 +473,8 @@ export default { zh: '间隔', en: 'Interval', }, + selectPlugin: { + zh: '请选择插件', + en: 'Select plugin', + }, } diff --git a/src/i18n/index.ts b/src/i18n/index.ts index b4faabcf..a26d5ae2 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -3,7 +3,7 @@ import { createI18n } from 'vue-i18n' import store from '@/store' import { setLang } from '@/composables/useLang' -const LangModules = ['data', 'common', 'config', 'admin', 'error', 'ekuiper', 'plugin', 'schema'] +const LangModules = ['data', 'common', 'config', 'admin', 'error', 'ekuiper', 'plugin', 'schema', 'template'] type LangModel = { [key in Language]: $TSFixed diff --git a/src/i18n/template.ts b/src/i18n/template.ts new file mode 100644 index 00000000..bff16d81 --- /dev/null +++ b/src/i18n/template.ts @@ -0,0 +1,49 @@ +/* + * @Author: gezuxia + * @Descripttion: + * @Date: 2023-05-18 15:18:50 + * @LastEditors: gezuxia + * @LastEditTime: 2023-05-18 19:07:27 + */ +export default { + templateName: { + zh: '模板名称', + en: 'Template Name', + }, + addTemplate: { + zh: '添加模板', + en: 'Add Template', + }, + editTemplate: { + zh: '编辑模板', + en: 'Edit Template', + }, + pluginName: { + zh: '插件名称', + en: 'Plugin name', + }, + importTemplate: { + zh: '导入模板', + en: 'Import Template', + }, + deleteTemplateTip: { + zh: '操作不可撤回,删除后会导致模版关联的节点和组也被删除,是否确认删除?', + en: 'The operation is irreversible. After deleting, the nodes and groups associated with the template will also be deleted. Do you confirm the deletion?', + }, + templateRemark: { + zh: '用于创建一个包含组和点位相关配置的设备实例。可基于模板创建多个具有相同点位的设备节点', + en: 'Used to create a device instance that contains group and point-related configurations. Multiple device nodes with the same point can be created based on a template', + }, + templateLink: { + zh: '模板文档', + en: 'Template document', + }, + missingNameInFile: { + zh: '缺少名称,请补充名称', + en: 'The name is missing, please add the plugin', + }, + missingPluginInFile: { + zh: '缺少插件,请补充插件', + en: 'The plugin is missing, please add the plugin', + }, +} diff --git a/src/router/routes.ts b/src/router/routes.ts index 10155e0a..14dcd58c 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -132,6 +132,21 @@ const routes: Array = [ }, ], }, + /** Template */ + { + path: '/configuration/template', + name: 'Template', + meta: { title: 'config.plugin' }, + component: Home, + children: [ + { + path: '', + name: 'Template', + component: () => import('@/views/config/template/Index.vue'), + meta: { hiddenBreadcrumb: true }, + }, + ], + }, /** Plugin */ { path: '/configuration/plugin', diff --git a/src/types/config.d.ts b/src/types/config.d.ts index 1be1243e..d83348cc 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -197,3 +197,17 @@ export interface AddTagListForm { export interface TagData extends TagForm { group: string } + +// Template +export interface RawTemplateData { + name: string + plugin: string +} +export interface TemplateGroup { + name: string + interval: number | null + tags?: Array +} +export interface TemplateFormData extends RawTemplateData { + groups?: Array +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 156bfd5d..4acf51b3 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -179,6 +179,7 @@ export const dataType = (value: any) => { ['[object Symbol]', 'symbol'], ['[object JSON]', 'json'], ['[object Math]', 'math'], + ['[object File]', 'file'], ['[object Blob]', 'blob'], ['[object ArrayBuffer]', 'arrayBuffer'], ['default', 'object'], @@ -193,3 +194,14 @@ export const isTheSameParentRoute = (from: any, to: any) => { const toRouteName = toMatched[0]?.name return fromRouteName === toRouteName } + +export const isJSONData = (data: string) => { + try { + JSON.parse(data) + return true + } catch (error) { + console.error(error) + EmqxMessage.error(i18n.global.t('common.jsonFormatError')) + return false + } +} diff --git a/src/views/config/components/PluginTypesSelector.vue b/src/views/config/components/PluginTypesSelector.vue index 4b5a465c..fe6aca55 100644 --- a/src/views/config/components/PluginTypesSelector.vue +++ b/src/views/config/components/PluginTypesSelector.vue @@ -2,9 +2,10 @@ @@ -15,13 +16,19 @@ import { computed, defineEmits, defineProps } from 'vue' import usePlugin from '@/composables/config/usePlugin' import { PluginKind } from '@/types/enums' +import { useI18n } from 'vue-i18n' const props = defineProps({ modelValue: { type: String, default: '' }, types: { type: Array, default: () => [] }, + placeholder: { type: String, default: '' }, + size: { type: String, default: '' }, + disabled: { type: Boolean, default: false }, }) const emits = defineEmits(['update:modelValue', 'change']) +const { t } = useI18n() + const pluginType = computed({ get: () => props.modelValue, set: (val) => { @@ -42,6 +49,10 @@ const directionPluginList = computed(() => { } return exceptStaticPluginList.value }) + +const selectorPlaceholder = computed(() => { + return props.placeholder || t('config.pluginKindPlaceholder') +}) const changePluginType = (val: string) => { emits('change', val) } diff --git a/src/views/config/southDriver/Index.vue b/src/views/config/southDriver/Index.vue index a941d615..fff93ca8 100644 --- a/src/views/config/southDriver/Index.vue +++ b/src/views/config/southDriver/Index.vue @@ -10,6 +10,7 @@ diff --git a/src/views/config/template/Index.vue b/src/views/config/template/Index.vue new file mode 100644 index 00000000..3ec4cfa6 --- /dev/null +++ b/src/views/config/template/Index.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/src/views/config/template/components/TemplateDialog.vue b/src/views/config/template/components/TemplateDialog.vue new file mode 100644 index 00000000..b319899b --- /dev/null +++ b/src/views/config/template/components/TemplateDialog.vue @@ -0,0 +1,92 @@ + + + + +