Merge branch 'dev' of github.com:jetlinks/jetlinks-ui-vue into dev

This commit is contained in:
jackhoo_98 2023-01-17 18:03:36 +08:00
commit 693da4ec6d
33 changed files with 5793 additions and 4377 deletions

View File

@ -19,6 +19,7 @@
"ant-design-vue": "^3.2.15",
"axios": "^1.2.1",
"echarts": "^5.4.1",
"event-source-polyfill": "^1.0.31",
"jetlinks-store": "^0.0.3",
"js-cookie": "^3.0.1",
"less": "^4.1.3",

25
src/api/comm.ts Normal file
View File

@ -0,0 +1,25 @@
import { BASE_API_PATH } from "@/utils/variable";
import server from '@/utils/request'
import { SearchHistoryList } from 'components/Search/types'
export const FILE_UPLOAD = `${BASE_API_PATH}/file/static`;
/**
*
* @param data
* @param target
*/
export const saveSearchHistory = (data: any, target:string) => server.post(`/user/settings/${target}`, data)
/**
*
* @param target
*/
export const getSearchHistory = (target:string) => server.get<SearchHistoryList[]>(`/user/settings/${target}`)
/**
*
* @param id
* @param target
*/
export const deleteSearchHistory = (target:string, id:string) => server.remove<SearchHistoryList[]>(`/user/settings/${target}/${id}`)

View File

@ -51,4 +51,25 @@ export const _deploy = (id: string) => server.post(`/device-instance/${id}/deplo
* @param data
* @returns
*/
export const _undeploy = (id: string) => server.post(`/device-instance/${id}/undeploy`)
export const _undeploy = (id: string) => server.post(`/device-instance/${id}/undeploy`)
/**
*
* @param data id数组
* @returns
*/
export const batchDeployDevice = (data: string[]) => server.put(`/device-instance/batch/_deploy`, data)
/**
*
* @param data id数组
* @returns
*/
export const batchUndeployDevice = (data: string[]) => server.put(`/device-instance/batch/_unDeploy`, data)
/**
*
* @param data id数组
* @returns
*/
export const batchDeleteDevice = (data: string[]) => server.put(`/device-instance/batch/_delete`, data)

View File

@ -36,4 +36,10 @@ export const getCodecs = () => server.get<{id: string, name: string}>('/device/p
* @param id ID
* @returns
*/
export const detail = (id: string) => server.get<ProductItem>(`/device-product/${id}`)
export const detail = (id: string) => server.get<ProductItem>(`/device-product/${id}`)
/**
*
* @param data
*/
export const category = (data: any) => server.post('/device/category/_tree', data)

View File

@ -3,4 +3,4 @@ import server from '@/utils/request';
// 保存
export const save_api = (data: any) => server.post(`/system/config/scope/_save`, data)
// 获取详情
export const getDetails_api = (data: any) => server.post(`/system/config/scopes`, data)
export const getDetails_api = (data: any) => server.post(`/system/config/scopes`, data)

View File

@ -21,7 +21,11 @@ const iconKeys = [
'StopOutlined',
'CheckOutlined',
'CloseOutlined',
'DownOutlined'
'DownOutlined',
'ImportOutlined',
'ExportOutlined',
'SyncOutlined',
'ExclamationCircleOutlined'
]
const Icon = (props: {type: string}) => {

View File

@ -31,7 +31,7 @@
v-model:value='formData.data[item.name]'
:options='item.options'
/>
<a-inputnumber
<a-input-number
v-else-if='item.component === componentType.inputNumber'
v-bind='item.componentProps'
v-model:value='formData.data[item.name]'

View File

@ -32,6 +32,7 @@ self.MonacoEnvironment = {
const props = defineProps({
modelValue: [String, Number],
theme: { type: String, default: 'vs-dark' },
});
const emit = defineEmits(['update:modelValue']);
@ -48,7 +49,7 @@ onMounted(() => {
tabSize: 2,
automaticLayout: true,
scrollBeyondLastLine: false,
theme: 'vs-dark', // : vs(), vs-dark(), hc-black()
theme: props.theme, // : vs(), vs-dark(), hc-black()
});
instance.onDidChangeModelContent(() => {

View File

@ -0,0 +1,125 @@
<template>
<a-dropdown-button
type='primary'
@click='click'
placement='bottomLeft'
:visible='historyVisible'
@visibleChange='visibleChange'
>
搜索
<template #overlay>
<a-menu>
<template v-if='!showEmpty'>
<a-menu-item v-for='item in historyList' :key='item.id'>
<div class='history-item'>
<span @click.stop='itemClick(item.content)'>{{ item.name }}</span>
<a-popconfirm
title='确认删除吗?'
placement='top'
@confirm.stop='deleteHistory(item.id)'
:okButtonProps='{
loading: deleteLoading
}'
>
<span class='delete'>
<DeleteOutlined />
</span>
</a-popconfirm>
</div>
</a-menu-item>
</template>
<template v-else>
<div class='history-empty'>
<a-empty />
</div>
</template>
</a-menu>
</template>
<template #icon>
<SearchOutlined />
</template>
</a-dropdown-button>
</template>
<script setup lang='ts' name='SearchHistory'>
import { SearchOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import { deleteSearchHistory, getSearchHistory } from '@/api/comm'
import type { SearchHistoryList } from 'components/Search/types'
type Emit = {
(event: 'click'): void
(event: 'itemClick', data: string): void
}
const emit = defineEmits<Emit>()
const props = defineProps({
target: {
type: String,
default: '',
required: true
}
})
const historyList = ref<SearchHistoryList[]>([])
const historyVisible = ref(false)
const deleteLoading = ref(false)
const showEmpty = ref(false)
const visibleChange = async (visible: boolean) => {
historyVisible.value = visible
if (visible) {
const resp = await getSearchHistory(props.target)
if (resp.success && resp.result.length) {
historyList.value = resp.result.filter(item => item.content)
showEmpty.value = false
} else {
showEmpty.value = true
}
}
}
const click = () => {
emit('click')
}
const itemClick = (content: string) => {
historyVisible.value = false
emit('itemClick', content)
}
const deleteHistory = async (id: string) => {
deleteLoading.value = true
const resp = await deleteSearchHistory(props.target, id)
deleteLoading.value = false
if (resp.success) {
historyVisible.value = false
}
}
</script>
<style scoped lang='less'>
.history-empty {
width: 200px;
background-color: #fff;
box-shadow: @box-shadow-base;
border-radius: 2px;
overflow-y: auto;
overflow-x: hidden;
max-height: 200px;
}
.history-item {
width: 200px;
display: flex;
> span {
flex: 1 1 auto;
}
.delete {
padding: 0 6px;
flex: 0 0 28px;
}
}
</style>

View File

@ -6,6 +6,7 @@
:options='typeOptions'
v-model:value='termsModel.type'
style='width: 100%;'
@change='valueChange'
/>
<span v-else>
{{
@ -17,65 +18,86 @@
class='JSearch-item--column'
:options='columnOptions'
v-model:value='termsModel.column'
@change='columnChange'
/>
<a-select
class='JSearch-item--termType'
:options='termTypeOptions'
:options='termTypeOptions.option'
v-model:value='termsModel.termType'
@change='termTypeChange'
/>
<div class='JSearch-item--value'>
<a-input
v-if='component === componentType.input'
v-model:value='termsModel.value'
@change='(v) => valueChange(v)'
style='width: 100%'
@change='valueChange'
/>
<a-select
v-else-if='component === componentType.select'
showSearch
:loading='optionLoading'
v-model:value='termsModel.value'
:options='options'
@change='(v) => valueChange(v)'
style='width: 100%'
:filterOption='(v, option) => filterTreeSelectNode(v, option, "label")'
@change='valueChange'
/>
<a-inputnumber
<a-input-number
v-else-if='component === componentType.inputNumber'
v-model:value='termsModel.value'
@change='(v) => valueChange(v)'
style='width: 100%'
@change='valueChange'
/>
<a-input-password
v-else-if='component === componentType.password'
v-model:value='termsModel.value'
@change='(v) => valueChange(v)'
style='width: 100%'
@change='valueChange'
/>
<a-switch
v-else-if='component === componentType.switch'
v-model:checked='termsModel.value'
@change='(v) => valueChange(v)'
style='width: 100%'
@change='valueChange'
/>
<a-radio-group
v-else-if='component === componentType.radio'
v-model:value='termsModel.value'
@change='(v) => valueChange(v)'
style='width: 100%'
@change='valueChange'
/>
<a-checkbox-group
v-else-if='component === componentType.checkbox'
v-model:value='termsModel.value'
:options='options'
@change='(v) => valueChange(v)'
style='width: 100%'
@change='valueChange'
/>
<a-time-picker
v-else-if='component === componentType.time'
valueFormat='HH:mm:ss'
v-model:value='termsModel.value'
@change='(v) => valueChange(v)'
style='width: 100%'
@change='valueChange'
/>
<a-date-picker
v-else-if='component === componentType.date'
showTime
v-model:value='termsModel.value'
@change='(v) => valueChange(v)'
valueFormat='YYYY-MM-DD HH:mm:ss'
style='width: 100%'
@change='valueChange'
/>
<a-tree-select
v-else-if='component === componentType.tree'
v-else-if='component === componentType.treeSelect'
showSearch
v-model:value='termsModel.value'
:tree-data='options'
@change='(v) => valueChange(v)'
style='width: 100%'
:fieldNames='{ label: "name", value: "id" }'
@change='valueChange'
:filterTreeNode='(v, option) => filterSelectNode(v, option)'
/>
</div>
</div>
@ -85,18 +107,19 @@
import { componentType } from 'components/Form'
import { typeOptions, termType } from './util'
import { PropType } from 'vue'
import type { SearchItemProps, SearchItemData } from './types'
import { cloneDeep } from 'lodash-es'
import type { SearchItemData, SearchProps, Terms } from './types'
import { cloneDeep, get, isArray, isFunction } from 'lodash-es'
import { filterTreeSelectNode, filterSelectNode } from '@/utils/comm'
type ItemDataProps = Omit<SearchItemData, 'title'>
type ItemType = SearchProps['type']
interface Emit {
(e: 'change', data: ItemDataProps): void
(e: 'change', data: SearchItemData): void
}
const props = defineProps({
columns: {
type: Array as PropType<SearchItemProps[]>,
type: Array as PropType<SearchProps[]>,
default: () => [],
required: true
},
@ -107,66 +130,146 @@ const props = defineProps({
expand: {
type: Boolean,
default: false
},
termsItem: {
type: Object as PropType<Terms>,
default: {}
}
})
const emit = defineEmits<Emit>()
const termsModel = reactive<ItemDataProps>({
const termsModel = reactive<SearchItemData>({
type: 'or',
value: '',
termType: 'eq',
termType: 'like',
column: ''
})
const component = ref(componentType.input)
const options = ref([])
const options = ref<any[]>([])
const columnOptions = ref<({ label: string, value: string})[]>([])
const columnOptionMap = new Map()
const termTypeOptions = reactive(termType)
const termTypeOptions = reactive({option: termType})
const getTermType = (type: string) => {
const optionLoading = ref(false)
/**
* 根据类型切换默termType值
* @param type
*/
const getTermType = (type?: ItemType) => {
termTypeOptions.option = termType
switch (type) {
case 'select':
case 'treeSelect':
case 'number':
return 'eq'
case 'date':
case 'time':
//
termTypeOptions.option = termType.filter(item => ['gt','lt'].includes(item.value))
return 'gt'
default:
return 'like'
}
}
/**
* 根据类型返回组件
* @param type
*/
const getComponent = (type?: ItemType) => {
switch (type) {
case 'select':
component.value = componentType.select
break;
case 'treeSelect':
component.value = componentType.treeSelect
break;
case 'date':
component.value = componentType.date
break;
case 'time':
component.value = componentType.time
break;
case 'number':
component.value = componentType.inputNumber
break;
default:
component.value = componentType.input
break;
}
}
const handleItemOptions = (option?: any[] | Function) => {
options.value = []
if (isArray(option)) {
options.value = option
} else if (isFunction(option)) {
optionLoading.value = true
option().then((res: any[]) => {
optionLoading.value = false
options.value = res
}).catch((_: any) => {
optionLoading.value = false
})
}
}
const columnChange = (value: string, isChange: boolean) => {
const item = columnOptionMap.get(value)
optionLoading.value = false
// valueundefined
termsModel.column = value
termsModel.termType = item.defaultTermType || getTermType(item.type)
getComponent(item.type) // Item
// options request
if ('options' in item) {
handleItemOptions(item.options)
}
termsModel.value = undefined
if (isChange) {
valueChange()
}
}
const handleItem = () => {
columnOptionMap.clear()
columnOptions.value = []
if (!props.columns.length) return
//
const sortColumn = cloneDeep(props.columns)
sortColumn?.sort((a, b) => a.sortIndex! - b.sortIndex!)
const _index = props.index > sortColumn.length ? sortColumn.length - 1 : props.index
const _itemColumn = sortColumn[_index - 1]
termsModel.column = _itemColumn.column
termsModel.termType = _itemColumn.defaultTermType || getTermType(_itemColumn.type as string)
columnOptions.value = props.columns.map(item => {
columnOptions.value = props.columns.map(item => { // columnsMap
columnOptionMap.set(item.column, item)
return {
label: item.title,
value: item.column
}
})
//
const sortColumn = cloneDeep(props.columns)
sortColumn?.sort((a, b) => a.sortIndex! - b.sortIndex!)
const _index = props.index > sortColumn.length ? sortColumn.length - 1 : props.index
const _itemColumn = sortColumn[_index - 1]
columnChange(_itemColumn.column, false)
}
const valueChange = (value: any) => {
const termTypeChange = () => {
valueChange()
}
const valueChange = () => {
emit('change', {
type: termsModel.type,
value: termsModel.value,
@ -177,6 +280,27 @@ const valueChange = (value: any) => {
handleItem()
watch( props.termsItem, (newValue) => {
const path = props.index < 4 ? [0, 'terms', props.index - 1] : [1, 'terms', props.index - 4]
const itemData: SearchItemData = get(newValue.terms, path)
if (itemData) {
termsModel.type = itemData.type
termsModel.column = itemData.column
termsModel.termType = itemData.termType
termsModel.value = itemData.value
const item = columnOptionMap.get(itemData.column)
getComponent(item.type) // Item
// options request
if ('options' in item) {
handleItemOptions(item.options)
}
} else {
handleItem()
}
}, { immediate: true, deep: true })
</script>
<style scoped lang='less'>

View File

@ -0,0 +1,100 @@
<template>
<a-popover
title='搜索名称'
trigger='click'
v-model:visible='visible'
@visibleChange='visibleChange'
>
<template #content>
<div style='width: 240px'>
<a-form ref='formRef' :model='modelRef'>
<a-form-item
name='name'
:rules='[
{ required: true, message: "请输入名称"}
]'
>
<a-textarea
v-model:value='modelRef.name'
:rows='3'
:maxlength='200'
/>
</a-form-item>
</a-form>
<a-button
:loading='saveHistoryLoading'
type='primary'
class='save-btn'
@click='saveHistory'
>
保存
</a-button>
</div>
</template>
<a-button>
<template #icon>
<SaveOutlined />
</template>
保存
</a-button>
</a-popover>
</template>
<script setup lang='ts' name='SaveHistory'>
import type { Terms } from './types'
import { PropType } from 'vue'
import { saveSearchHistory } from '@/api/comm'
import { SaveOutlined } from '@ant-design/icons-vue';
const props = defineProps({
terms: {
type: Object as PropType<Terms>,
default: () => ({})
},
target: {
type: String,
default: '',
required: true
}
})
const searchName = ref('')
const saveHistoryLoading = ref(false)
const visible = ref(false)
const formRef = ref()
const modelRef = reactive({
name: undefined
})
/**
* 保存当前查询条件
*/
const saveHistory = async () => {
//
const formData = await formRef.value.validate()
if (formData) {
formData.content = JSON.stringify(props.terms)
saveHistoryLoading.value = true
const resp = await saveSearchHistory(formData, props.target)
saveHistoryLoading.value = false
if (resp.success) {
visible.value = false
}
}
}
const visibleChange = (e: boolean) => {
visible.value = e
}
</script>
<style scoped lang='less'>
.save-btn {
width: 100%
}
</style>

View File

@ -4,9 +4,9 @@
<div v-if='props.type === "advanced"' :class='["JSearch-content senior", expand ? "senior-expand" : "", screenSize ? "big" : "small"]'>
<div :class='["JSearch-items", expand ? "items-expand" : "", layout]'>
<div class='left'>
<SearchItem :expand='expand' :index='1' :columns='searchItems' />
<SearchItem v-if='expand' :expand='expand' :index='2' :columns='searchItems' />
<SearchItem v-if='expand' :expand='expand' :index='3' :columns='searchItems' />
<SearchItem :expand='expand' :index='1' :columns='searchItems' @change='(v) => itemValueChange(v, 1)' :termsItem='terms'/>
<SearchItem v-if='expand' :expand='expand' :index='2' :columns='searchItems' @change='(v) => itemValueChange(v, 2)' :termsItem='terms'/>
<SearchItem v-if='expand' :expand='expand' :index='3' :columns='searchItems' @change='(v) => itemValueChange(v, 3)' :termsItem='terms'/>
</div>
<div class='center' v-if='expand'>
<a-select
@ -16,31 +16,17 @@
/>
</div>
<div class='right' v-if='expand'>
<SearchItem :expand='expand' :index='4' :columns='searchItems' />
<SearchItem :expand='expand' :index='5' :columns='searchItems' />
<SearchItem :expand='expand' :index='6' :columns='searchItems' />
<SearchItem :expand='expand' :index='4' :columns='searchItems' @change='(v) => itemValueChange(v, 4)' :termsItem='terms'/>
<SearchItem :expand='expand' :index='5' :columns='searchItems' @change='(v) => itemValueChange(v, 5)' :termsItem='terms'/>
<SearchItem :expand='expand' :index='6' :columns='searchItems' @change='(v) => itemValueChange(v, 6)' :termsItem='terms'/>
</div>
</div>
<div :class='["JSearch-footer", expand ? "expand" : ""]'>
<div class='JSearch-footer--btns'>
<a-dropdown-button type="primary">
搜索
<template #overlay>
<a-menu v-if='!!historyList.length'>
<a-menu-item>
</a-menu-item>
</a-menu>
<a-empty v-else />
</template>
<template #icon><SearchOutlined /></template>
</a-dropdown-button>
<a-button>
<template #icon><PoweroffOutlined /></template>
保存
</a-button>
<a-button>
<template #icon><PoweroffOutlined /></template>
<History :target='target' @click='searchSubmit' @itemClick='historyItemClick' />
<SaveHistory :terms='terms' :target='target'/>
<a-button @click='reset'>
<template #icon><RedoOutlined /></template>
重置
</a-button>
</div>
@ -54,17 +40,17 @@
<div v-else class='JSearch-content simple big'>
<div class='JSearch-items'>
<div class='left'>
<SearchItem :expand='false' :index='1' />
<SearchItem :expand='false' :index='1' :columns='searchItems' @change='(v) => itemValueChange(v, 1)' :termsItem='terms'/>
</div>
</div>
<div class='JSearch-footer'>
<div class='JSearch-footer--btns'>
<a-button type="primary">
<a-button type="primary" @click='searchSubmit'>
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button>
<template #icon><PoweroffOutlined /></template>
<a-button @click='reset'>
<template #icon><RedoOutlined /></template>
重置
</a-button>
</div>
@ -76,18 +62,25 @@
<script setup lang='ts' name='Search'>
import SearchItem from './Item.vue'
import { typeOptions } from './util'
import { useElementSize } from '@vueuse/core'
import { omit } from 'lodash-es'
import { SearchOutlined, DownOutlined } from '@ant-design/icons-vue';
import type { SearchItemProps } from './types'
import { useElementSize, useUrlSearchParams } from '@vueuse/core'
import { cloneDeep, isFunction, isString, set } from 'lodash-es'
import { SearchOutlined, DownOutlined, RedoOutlined } from '@ant-design/icons-vue';
import { PropType } from 'vue'
import { JColumnsProps } from 'components/Table/types'
import SaveHistory from './SaveHistory.vue'
import History from './History.vue'
import type { SearchItemData, SearchProps, Terms } from './types'
type UrlParam = {
q: string | null
target: string | null
}
interface Emit {
(e: 'search', data: Terms): void
}
const props = defineProps({
defaultParams: {
type: Object,
default: () => ({})
},
columns: {
type: Array as PropType<JColumnsProps[]>,
default: () => [],
@ -97,7 +90,7 @@ const props = defineProps({
type: String,
default: 'advanced'
},
key: {
target: {
type: String,
default: '',
required: true
@ -107,11 +100,13 @@ const props = defineProps({
const searchRef = ref(null)
const { width } = useElementSize(searchRef)
const urlParams = useUrlSearchParams<UrlParam>('hash')
//
const expand = ref(false)
//
const termType = ref('or')
const termType = ref('and')
//
const historyList = ref([])
@ -120,7 +115,13 @@ const layout = ref('horizontal')
// true 1000
const screenSize = ref(true)
const searchItems = ref<SearchItemProps[]>([])
const searchItems = ref<SearchProps[]>([])
//
const terms = reactive<Terms>({ terms: [] })
const columnOptionMap = new Map()
const emit = defineEmits<Emit>()
const expandChange = () => {
expand.value = !expand.value
@ -132,10 +133,12 @@ const searchParams = reactive({
const handleItems = () => {
searchItems.value = []
columnOptionMap.clear()
props.columns!.forEach((item, index) => {
if (item.search && Object.keys(item.search).length) {
columnOptionMap.set(item.dataIndex, item.search)
searchItems.value.push({
...omit(item.search, ['rename']),
...item.search,
sortIndex: item.search.first ? 0 : index + 1,
title: item.title,
column: item.dataIndex,
@ -144,6 +147,92 @@ const handleItems = () => {
})
}
const itemValueChange = (value: SearchItemData, index: number) => {
if (index < 4) { //
set(terms.terms, [0, 'terms', index - 1], value)
} else { //
set(terms.terms, [1, 'terms', index - 4], value)
}
}
const addUrlParams = () => {
urlParams.q = JSON.stringify(terms)
urlParams.target = props.target
}
/**
* 处理termType为likenlike的值
* @param v
*/
const handleLikeValue = (v: string) => {
if (isString(v)) {
return v.split('').reduce((pre: string, next: string) => {
let _next = next
if (next === '\\') {
_next = '\\\\'
} else if (next === '%') {
_next = '\\%'
}
return pre + _next
}, '')
}
return v
}
/**
* 处理为外部使用
*/
const handleParamsFormat = () => {
// termsvalueitem
const cloneParams = cloneDeep(terms)
return {
terms: cloneParams.terms.map(item => {
if (item.terms) {
item.terms = item.terms.filter(iItem => iItem && iItem.value)
.map(iItem => {
// handleValuerename
const _item = columnOptionMap.get(iItem.column)
if (_item.rename) {
iItem.column = _item.rename
}
if (_item.handleValue && isFunction(_item.handleValue)) {
iItem.value = _item.handleValue(iItem.value)
}
if (['like','nlike'].includes(iItem.termType) && !!iItem.value) {
iItem.value = `%${handleLikeValue(iItem.value)}%`
}
return iItem
})
}
return item
})
}
}
/**
* 提交
*/
const searchSubmit = () => {
emit('search', handleParamsFormat())
if (props.type === 'advanced') {
addUrlParams()
}
}
/**
* 重置查询
*/
const reset = () => {
terms.terms = []
expand.value = false
if (props.type === 'advanced') {
urlParams.q = null
urlParams.target = null
}
}
watch(width, (value) => {
if (value < 1000) {
layout.value = 'vertical'
@ -154,6 +243,41 @@ watch(width, (value) => {
}
})
const historyItemClick = (content: string) => {
try {
terms.terms = JSON.parse(content)?.terms || []
if (terms.terms.length === 2) {
expand.value = true
}
addUrlParams()
} catch (e) {
console.warn(`Search组件中handleUrlParams处理JSON时异常${e}`)
}
}
/**
* 处理URL中的查询参数
* @param _params
*/
const handleUrlParams = (_params: UrlParam) => {
// URLtargetprops
if (_params.target === props.target && _params.q) {
try {
terms.terms = JSON.parse(_params.q)?.terms || []
if (terms.terms.length === 2) {
expand.value = true
}
emit('search', handleParamsFormat())
} catch (e) {
console.warn(`Search组件中handleUrlParams处理JSON时异常${e}`)
}
}
}
nextTick(() => {
handleUrlParams(urlParams)
})
handleItems()
</script>
@ -253,4 +377,5 @@ handleItems()
}
}
}
</style>

View File

@ -0,0 +1,107 @@
# Search组件
- 需要结合Table使用
## 属性
| 名称 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| columns | 查询下拉列表 | JColumnsProps[] | [] |
| type | 查询模式 | 'advanced', 'simple' | 'advanced' |
| target | 查询组件唯一key | String | |
| search | 查询回调事件 | Function | |
> JColumnsProps[*].search
| 名称 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| rename | 用来重命名查询字段值 | String | |
| type | 查询值组件类型 | 'select', 'number', 'string', 'treeSelect', 'date', 'time' | |
| options | Select和TreeSelect组件下拉值 | Array, Promise | |
| first | 控制查询字段下拉默认值默认为name即名称 | Boolean | |
| defaultTermType | 查询条件 | String | |
| handleValue | 处理单个查询value值 | Function | |
## 基础用法
> columns中包含search属性才会出现在查询下拉中
```vue
const columns = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
search: {
type: 'string',
}
}
]
const search = (params) => {
}
<Search
:columns='columns'
target='device'
@search='search'
/>
```
> rename的作用在于search抛出params会根据rename修改数据中column的值
```vue
const columns = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
search: {
type: 'string',
rename: 'TestName'
}
}
]
const search = (params) => {
terms: [
{
column: 'TestName',
value: '',
termType: 'like'
}
]
}
<Search
:columns='columns'
target='device'
@search='search'
/>
```
> defaultTermType的作用在于设置查询条件,相关条件参考util中的termType
```vue
const columns = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
search: {
type: 'string',
defaultTermType: 'gt'
}
}
]
const search = (params) => {
terms: [
{
column: 'TestName',
value: '',
termType: 'gt'
}
]
}
<Search
:columns='columns'
target='device'
@search='search'
/>
```

View File

@ -1,4 +1,4 @@
export interface SearchProps {
export interface SearchBaseProps {
rename?: string
type?: 'select' | 'number' | 'string' | 'treeSelect' | 'date' | 'time'
format?: string
@ -7,15 +7,43 @@ export interface SearchProps {
defaultTermType?: string // 默认 eq
title?: ColumnType.title
sortIndex?: number
handleValue?: (value: SearchItemData) => any
}
export interface SearchItemProps {
rename?: SearchBaseProps['rename']
title: string
column: ColumnType.dataIndex
}
export interface SearchItemData {
column: ColumnType.dataIndex
rename?: string
value: any
termType: string
type?: string
title: string
}
export interface SearchItemProps extends SearchProps, SearchItemData {}
export interface TermsItem {
terms: SearchItemData[]
}
export interface Terms {
terms: TermsItem[]
}
export interface SortItem {
name: string
order?: 'desc' | 'asc'
value?: any
}
export interface SearchHistoryList {
content?: string
name: string
id: string
key: string
}
export interface SearchProps extends SearchBaseProps, SearchItemProps {
}

View File

@ -204,9 +204,13 @@ const JTable = defineComponent<JTableProps>({
loading.value = false
}
watchEffect(() => {
handleSearch(props.params)
})
watch(
() => props.params,
(newValue) => {
handleSearch(newValue)
},
{deep: true, immediate: true}
)
onMounted(() => {
window.onresize = () => {
@ -266,7 +270,7 @@ const JTable = defineComponent<JTableProps>({
onClose={() => {
emit('cancelSelect')
}}
closeText={<a></a>}
closeText={<a-button type="link"></a-button>}
/>
</div> : null
}

View File

@ -1,7 +1,7 @@
import { SearchItemProps } from 'components/Search/types'
import { SearchProps } from 'components/Search/types'
import { ColumnType } from 'ant-design-vue/es/table'
export interface JColumnsProps extends ColumnType{
scopedSlots?: boolean;
search: SearchItemProps
search: SearchProps
}

View File

@ -45,7 +45,7 @@
<template #addonAfter>
<a-upload
name="file"
:action="action"
:action="FILE_UPLOAD"
:headers="headers"
:showUploadList="false"
@change="handleFileChange"
@ -89,6 +89,7 @@ import GeoComponent from '@/components/GeoComponent/index.vue';
import { BASE_API_PATH, TOKEN_KEY } from '@/utils/variable';
import { LocalStore } from '@/utils/comm';
import { ItemData, ITypes } from './types';
import { FILE_UPLOAD } from '@/api/comm';
type Emits = {
(e: 'update:modelValue', data: string | number | boolean): void;
@ -161,7 +162,6 @@ const handleItemModalSubmit = () => {
};
//
const action = ref<string>(`${BASE_API_PATH}/file/static`);
const headers = ref({ [TOKEN_KEY]: LocalStore.get(TOKEN_KEY) });
const handleFileChange = (info: UploadChangeParam<UploadFile<any>>) => {
if (info.file.status === 'done') {

View File

@ -1,4 +1,5 @@
import { TOKEN_KEY } from '@/utils/variable'
import { Terms } from 'components/Search/types'
/**
*
@ -35,4 +36,24 @@ export const LocalStore = {
export const getToken = () => {
return LocalStore.get(TOKEN_KEY)
}
}
/**
* TreeSelect过滤
* @param value
* @param treeNode
* @param key
*/
export const filterTreeSelectNode = (value: string, treeNode: any, key: string = 'name'): boolean => {
return treeNode[key]?.includes(value)
}
/**
* Select过滤
* @param value
* @param option
* @param key
*/
export const filterSelectNode = (value: string, option: any, key: string = 'label'): boolean => {
return option[key]?.includes(value)
}

64
src/utils/encodeQuery.ts Normal file
View File

@ -0,0 +1,64 @@
export default function encodeQuery(params: any) {
if (!params) return {};
const queryParam = {
// pageIndex: 0,
current: params.current,
};
const { terms, sorts } = params;
Object.keys(params).forEach((key: string) => {
if (key === 'terms') {
let index = 0;
if (!terms) return;
Object.keys(terms).forEach((k: string) => {
if (
!(
terms[k] === '' ||
terms[k] === undefined ||
terms[k].length === 0 ||
terms[k] === {} ||
terms[k] === null
)
) {
if (k.indexOf('$LIKE') > -1 && terms[k].toString().indexOf('%') === -1) {
terms[k] = `%${terms[k]}%`;
}
if (k.indexOf('$IN') > -1) {
terms[k] = terms[k].toString();
} else if (k.indexOf('$START') > -1) {
terms[k] = `%${terms[k]}`;
} else if (k.indexOf('$END') > -1) {
terms[k] = `${terms[k]}%`;
}
if (k.indexOf('@') > -1) {
const temp = k.split('@');
// eslint-disable-next-line prefer-destructuring
queryParam[`terms[${index}].column`] = temp[0];
// eslint-disable-next-line prefer-destructuring
queryParam[`terms[${index}].type`] = temp[1];
} else {
queryParam[`terms[${index}].column`] = k;
}
queryParam[`terms[${index}].value`] = terms[k];
index += 1;
}
});
} else if (key === 'sorts') {
// 当前Ant Design排序只支持单字段排序
if (!sorts) return;
Object.keys(sorts).forEach((s, index) => {
queryParam[`sorts[${index}].name`] = s;
queryParam[`sorts[${index}].order`] = sorts[s].replace('end', '');
});
// if (Object.keys(sorts).length > 0) {
// queryParam[`sorts[0].name`] = sorts.field;
// queryParam[`sorts[0].order`] = (sorts.order || '').replace('end', '');
// }
} else {
queryParam[key] = params[key];
}
});
// queryParam.pageIndex = current - 1;
return queryParam;
}

View File

@ -1,3 +1,7 @@
import moment from "moment";
import { LocalStore } from "./comm";
import { TOKEN_KEY } from "./variable";
/**
* JSON
* @param record
@ -18,4 +22,34 @@ export const downloadObject = (record: Record<string, any>, fileName: string, fo
ghostLink.click();
//移除
document.body.removeChild(ghostLink);
};
/**
*
* @param url
* @param params
*/
export const downloadFile = (url: string, params?: Record<string, any>) => {
const formElement = document.createElement('form');
formElement.style.display = 'display:none;';
formElement.method = 'GET';
formElement.action = url;
// 添加参数
if (params) {
Object.keys(params).forEach((key: string) => {
const inputElement = document.createElement('input');
inputElement.type = 'hidden';
inputElement.name = key;
inputElement.value = params[key];
formElement.appendChild(inputElement);
});
}
const inputElement = document.createElement('input');
inputElement.type = 'hidden';
inputElement.name = ':X_Access_Token';
inputElement.value = LocalStore.get(TOKEN_KEY);
formElement.appendChild(inputElement);
document.body.appendChild(formElement);
formElement.submit();
document.body.removeChild(formElement);
};

View File

@ -2,13 +2,22 @@
<div class='search'>
<Search
:columns='columns'
target='device-instance-search'
@search='search'
/>
<Search
type='simple'
:columns='columns'
target='product'
@search='search'
/>
<Search type='simple' />
</div>
</template>
<script setup name='demoSearch'>
import { category } from '../../api/device/product'
const columns = [
{
title: '名称',
@ -17,20 +26,59 @@ const columns = [
search: {
rename: 'deviceId',
type: 'select',
handValue: (v) => {
options: [
{
label: '测试1',
value: 'test1'
},
{
label: '测试2',
value: 'test2'
},
{
label: '测试3',
value: 'test3'
},
],
handleValue: (v) => {
return '123'
}
}
},
{
title: '序号',
dataIndex: 'sortIndex',
key: 'sortIndex',
scopedSlots: true,
search: {
type: 'number',
}
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
scopedSlots: true,
search: {
type: 'string',
}
},
{
title: '时间',
dataIndex: 'date',
key: 'date',
search: {
type: 'date',
}
},
{
title: '时间2',
dataIndex: 'date2',
key: 'date2',
search: {
type: 'time',
defaultTermType: 'lt'
}
},
{
title: '分类',
dataIndex: 'classifiedName',
@ -38,9 +86,13 @@ const columns = [
search: {
first: true,
type: 'treeSelect',
// options: async () => {
// return await
// }
options: async () => {
return new Promise((res) => {
category().then(resp => {
res(resp.result)
})
})
}
}
},
{
@ -51,6 +103,9 @@ const columns = [
scopedSlots: true,
}
]
const search = (params) => {
console.log(params)
}
</script>
<style scoped>

View File

@ -0,0 +1,75 @@
<template>
<a-modal :maskClosable="false" width="800px" :visible="true" title="导出" @ok="handleOk" @cancel="handleCancel">
<div style="background-color: rgb(236, 237, 238)">
<p style="padding: 10px">
<AIcon type="ExclamationCircleOutlined" />
选择单个产品时可导出其下属设备的详细数据,不选择产品时导出所有设备的基础数据
</p>
</div>
<div style="margin-top: 20px">
<a-form :layout="'vertical'">
<a-form-item label="产品">
<a-select showSearch v-model:value="modelRef.product" placeholder="请选择产品">
<a-select-option :value="item.id" v-for="item in productList" :key="item.id" :title="item.name"></a-select-option>
</a-select>
</a-form-item>
<a-form-item label="文件格式">
<a-radio-group button-style="solid" v-model:value="modelRef.fileType" placeholder="请选择文件格式">
<a-radio-button value="xlsx">xlsx</a-radio-button>
<a-radio-button value="csv">csv</a-radio-button>
</a-radio-group>
</a-form-item>
</a-form>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { queryNoPagingPost } from '@/api/device/product'
import { downloadFile } from '@/utils/utils'
import encodeQuery from '@/utils/encodeQuery'
import { BASE_API_PATH } from '@/utils/variable'
const emit = defineEmits(['close'])
const props = defineProps({
data: {
type: Object,
default: undefined
}
})
const modelRef = reactive({
product: undefined,
fileType: 'xlsx'
});
const productList = ref<Record<string, any>[]>([])
watch(
() => props.data,
() => {
queryNoPagingPost({paging: false}).then(resp => {
if(resp.status === 200){
productList.value = resp.result as Record<string, any>[]
}
})
},
{immediate: true, deep: true}
)
const handleOk = () => {
const params = encodeQuery(props.data);
if(modelRef.product){
downloadFile(
`${BASE_API_PATH}/device/instance/${modelRef.product}/export.${modelRef.fileType}`,
params
);
} else {
downloadFile(`${BASE_API_PATH}/device/instance/export.${modelRef.fileType}`, params);
}
emit('close')
}
const handleCancel = () => {
emit('close')
}
</script>

View File

@ -0,0 +1,14 @@
<template>
<a-modal :maskClosable="false" width="800px" :visible="true" title="导入" @ok="handleOk" @cancel="handleCancel">
123
</a-modal>
</template>
<script lang="ts" setup>
const handleOk = () => {
}
const handleCancel = () => {
}
</script>

View File

@ -0,0 +1,51 @@
<template>
<a-modal :maskClosable="false" width="800px" :visible="true" title="当前进度" @ok="handleOk" @cancel="handleCancel">
<div>
<a-badge v-if="flag" status="processing" text="进行中" />
<a-badge v-else status="success" text="已完成" />
</div>
<p>总数量{{count}}</p>
<a></a>
</a-modal>
</template>
<script lang="ts" setup>
import { downloadFile } from '@/utils/utils'
const emit = defineEmits(['close'])
const props = defineProps({
api: {
type: String,
default: ''
},
type: {
type: String,
default: ''
}
})
const eventSource = ref<Record<string, any>>({})
const count = ref<number>(0)
const flag = ref<boolean>(false)
const errMessage = ref<string>('')
const isSource = ref<boolean>(false)
const id = ref<string>('')
const handleOk = () => {
emit('close')
}
const handleCancel = () => {
emit('close')
}
const getData = () => {
}
watch(() => props.api,
() => {
getData()
},
{deep: true, immediate: true}
)
</script>

View File

@ -1,5 +1,16 @@
<template>
<JTable ref="instanceRef" :columns="columns" :request="query" :defaultParams="{sorts: [{name: 'createTime', order: 'desc'}]}" :params="{pageIndex: 0, pageSize: 12}">
<JTable
ref="instanceRef"
:columns="columns"
:request="query"
:defaultParams="{sorts: [{name: 'createTime', order: 'desc'}]}"
:rowSelection="{
selectedRowKeys: _selectedRowKeys,
onChange: onSelectChange
}"
@cancelSelect="cancelSelect"
:params="params"
>
<template #headerTitle>
<a-space>
<a-button type="primary" @click="handleAdd">新增</a-button>
@ -8,13 +19,33 @@
<template #overlay>
<a-menu>
<a-menu-item>
<a href="javascript:;">1st menu item</a>
<a-button @click="exportVisible = true"><AIcon type="ExportOutlined" />批量导出设备</a-button>
</a-menu-item>
<a-menu-item>
<a href="javascript:;">2nd menu item</a>
<a-button @click="importVisible = true"><AIcon type="ImportOutlined" />批量导入设备</a-button>
</a-menu-item>
<a-menu-item>
<a href="javascript:;">3rd menu item</a>
<a-popconfirm @confirm="activeAllDevice" title="确认激活全部设备?">
<a-button type="primary" ghost><AIcon type="CheckCircleOutlined" />激活全部设备</a-button>
</a-popconfirm>
</a-menu-item>
<a-menu-item>
<a-button @click="syncDeviceStatus" type="primary"><AIcon type="SyncOutlined" />同步设备状态</a-button>
</a-menu-item>
<a-menu-item v-if="_selectedRowKeys.length">
<a-popconfirm @confirm="delSelectedDevice" title="已启用的设备无法删除,确认删除选中的禁用状态设备?">
<a-button type="primary" danger><AIcon type="DeleteOutlined" />删除选中设备</a-button>
</a-popconfirm>
</a-menu-item>
<a-menu-item v-if="_selectedRowKeys.length" title="确认激活选中设备?">
<a-popconfirm @confirm="activeSelectedDevice" >
<a-button type="primary"><AIcon type="CheckOutlined" />激活选中设备</a-button>
</a-popconfirm>
</a-menu-item>
<a-menu-item v-if="_selectedRowKeys.length">
<a-popconfirm @confirm="disabledSelectedDevice" title="确认禁用选中设备?">
<a-button type="primary" danger><AIcon type="StopOutlined" />禁用选中设备</a-button>
</a-popconfirm>
</a-menu-item>
</a-menu>
</template>
@ -24,9 +55,10 @@
<template #card="slotProps">
<CardBox
:value="slotProps"
@click="handleView"
@click="handleClick"
:actions="getActions(slotProps, 'card')"
v-bind="slotProps"
:active="_selectedRowKeys.includes(slotProps.id)"
:status="slotProps.state.value"
:statusText="slotProps.state.text"
:statusNames="{
@ -41,7 +73,7 @@
</slot>
</template>
<template #content>
<h3>{{ slotProps.name }}</h3>
<h3 @click="handleView(slotProps.id)">{{ slotProps.name }}</h3>
<a-row>
<a-col :span="12">
<div class="card-item-content-text">设备类型</div>
@ -116,15 +148,30 @@
</a-space>
</template>
</JTable>
<Import v-if="importVisible" @close="importVisible = false" />
<Export v-if="exportVisible" @close="exportVisible = false" :data="params" />
<Process v-if="operationVisible" @close="operationVisible = false" :api="api" :type="type" />
</template>
<script setup lang="ts">
import { query, _delete, _deploy, _undeploy } from '@/api/device/instance'
import { query, _delete, _deploy, _undeploy, batchUndeployDevice, batchDeployDevice, batchDeleteDevice } from '@/api/device/instance'
import type { ActionsType } from '@/components/Table/index.vue'
import { getImage } from '@/utils/comm';
import { getImage, LocalStore } from '@/utils/comm';
import { message } from "ant-design-vue";
import Import from './Import/index.vue'
import Export from './Export/index.vue'
import Process from './Process/index.vue'
import { BASE_API_PATH, TOKEN_KEY } from '@/utils/variable';
const instanceRef = ref<Record<string, any>>({});
const params = ref<Record<string, any>>({pageIndex: 0, pageSize: 12})
const _selectedRowKeys = ref<string[]>([])
const importVisible = ref<boolean>(false)
const exportVisible = ref<boolean>(false)
const current = ref<Record<string, any>>({})
const operationVisible = ref<boolean>(false)
const api = ref<string>('')
const type = ref<string>('')
const statusMap = new Map();
statusMap.set('online', 'processing');
@ -173,12 +220,45 @@ const columns = [
}
]
const paramsFormat = (config: any, _terms: any, name?: string) => {
if (config?.terms && Array.isArray(config.terms) && config?.terms.length > 0) {
(config?.terms || []).map((item: any, index: number) => {
if (item?.type) {
_terms[`${name ? `${name}.` : ''}terms[${index}].type`] = item.type;
}
paramsFormat(item, _terms, `${name ? `${name}.` : ''}terms[${index}]`);
});
} else if (!config?.terms && Object.keys(config).length > 0) {
Object.keys(config).forEach((key) => {
if (config[key]) {
_terms[`${name ? `${name}.` : ''}${key}`] = config[key];
}
});
}
}
const handleParams = (config: any) => {
const _terms: any = {};
paramsFormat(config, _terms);
const url = new URLSearchParams();
Object.keys(_terms).forEach((key) => {
url.append(key, _terms[key]);
});
return url.toString();
}
/**
* 新增
*/
const handleAdd = () => {
message.warn('123')
message.warn('新增')
}
/**
* 查看
*/
const handleView = (dt: any) => {
// message.warn('')
}
const getActions = (data: Partial<Record<string, any>>, type: 'card' | 'table'): ActionsType[] => {
@ -257,4 +337,61 @@ const getActions = (data: Partial<Record<string, any>>, type: 'card' | 'table'):
return actions
}
const onSelectChange = (keys: string[]) => {
_selectedRowKeys.value = [...keys]
}
const cancelSelect = () => {
_selectedRowKeys.value = []
}
const handleClick = (dt: any) => {
if(_selectedRowKeys.value.includes(dt.id)) {
const _index = _selectedRowKeys.value.findIndex(i => i === dt.id)
_selectedRowKeys.value.splice(_index, 1)
} else {
_selectedRowKeys.value = [..._selectedRowKeys.value, dt.id]
}
}
const activeAllDevice = () => {
type.value = 'active'
const activeAPI = `/${BASE_API_PATH}/device-instance/deploy?:X_Access_Token=${LocalStore.get(TOKEN_KEY)}&${handleParams(params)}`;
api.value = activeAPI
operationVisible.value = true
}
const syncDeviceStatus = () => {
type.value = 'sync'
const syncAPI = `/${BASE_API_PATH}/device-instance/state/_sync?:X_Access_Token=${LocalStore.get(TOKEN_KEY)}&${handleParams(params)}`;
api.value = syncAPI
operationVisible.value = true
}
const delSelectedDevice = async () => {
const resp = await batchDeleteDevice(_selectedRowKeys.value)
if(resp.status === 200){
message.success('操作成功!')
_selectedRowKeys.value = []
instanceRef.value?.reload()
}
}
const activeSelectedDevice = async () => {
const resp = await batchDeployDevice(_selectedRowKeys.value)
if(resp.status === 200){
message.success('操作成功!')
_selectedRowKeys.value = []
instanceRef.value?.reload()
}
}
const disabledSelectedDevice = async () => {
const resp = await batchUndeployDevice(_selectedRowKeys.value)
if(resp.status === 200){
message.success('操作成功!')
_selectedRowKeys.value = []
instanceRef.value?.reload()
}
}
</script>

View File

@ -313,7 +313,6 @@ const formData = ref<ConfigFormData>({
configuration: {
appKey: '',
appSecret: '',
url: '',
},
description: '',
name: '',
@ -325,13 +324,20 @@ const formData = ref<ConfigFormData>({
watch(
() => formData.value.type,
(val) => {
formData.value.configuration = CONFIG_FIELD_MAP[val];
// formData.value.configuration = Object.values<any>(CONFIG_FIELD_MAP[val])[0];
msgType.value = MSG_TYPE[val];
formData.value.provider = msgType.value[0].value;
},
);
computed(() =>
Object.assign(
formData.value.configuration,
CONFIG_FIELD_MAP[formData.value.type][formData.value.provider],
),
);
//
const formRules = ref({
type: [{ required: true, message: '请选择通知方式' }],

View File

@ -3,34 +3,62 @@ export interface IHeaders {
key: string;
value: string;
}
export interface IConfiguration {
// 钉钉
appKey?: string;
appSecret?: string;
url?: string;
// 微信
corpId?: string;
corpSecret?: string;
// 邮件
host?: string;
port?: number;
ssl?: boolean;
sender?: string;
username?: string;
password?: string;
// 语音
regionId?: string;
accessKeyId?: string;
secret?: string;
// 短信
regionId?: string;
accessKeyId?: string;
secret?: string;
// webhook
// url?: string;
headers?: IHeaders[];
}
export type ConfigFormData = {
configuration: {
// 钉钉
appKey?: string;
appSecret?: string;
url?: string;
// 微信
corpId?: string;
corpSecret?: string;
// 邮件
host?: string;
port?: number;
ssl?: boolean;
sender?: string;
username?: string;
password?: string;
// 语音
regionId?: string;
accessKeyId?: string;
secret?: string;
// 短信
regionId?: string;
accessKeyId?: string;
secret?: string;
// webhook
// url?: string;
headers?: IHeaders[];
};
configuration: IConfiguration;
// configuration: {
// // 钉钉
// appKey?: string;
// appSecret?: string;
// url?: string;
// // 微信
// corpId?: string;
// corpSecret?: string;
// // 邮件
// host?: string;
// port?: number;
// ssl?: boolean;
// sender?: string;
// username?: string;
// password?: string;
// // 语音
// regionId?: string;
// accessKeyId?: string;
// secret?: string;
// // 短信
// regionId?: string;
// accessKeyId?: string;
// secret?: string;
// // webhook
// // url?: string;
// headers?: IHeaders[];
// };
description: string;
name: string;
provider: string;

View File

@ -0,0 +1,105 @@
<!-- webhook请求头可编辑表格 -->
<template>
<div class="attachment-wrapper">
<div
class="attachment-item"
v-for="(item, index) in fileList"
:key="index"
>
<a-input v-model:value="item.name">
<template #addonAfter>
<a-upload
name="file"
:action="FILE_UPLOAD"
:headers="{
[TOKEN_KEY]: LocalStore.get(TOKEN_KEY),
}"
:showUploadList="false"
@change="handleChange"
>
<upload-outlined />
</a-upload>
</template>
</a-input>
<delete-outlined @click="handleDelete" style="cursor: pointer" />
</div>
<a-button
type="dashed"
@click="handleAdd"
style="width: 100%; margin-top: 5px"
>
<template #icon>
<plus-outlined />
</template>
添加
</a-button>
</div>
</template>
<script setup lang="ts" name="Attachments">
import {
PlusOutlined,
DeleteOutlined,
UploadOutlined,
} from '@ant-design/icons-vue';
import { PropType } from 'vue';
import { IAttachments } from '../../types';
import { FILE_UPLOAD } from '@/api/comm';
import { LocalStore } from '@/utils/comm';
import { TOKEN_KEY } from '@/utils/variable';
import { UploadChangeParam } from 'ant-design-vue';
type Emits = {
(e: 'update:attachments', data: IAttachments[]): void;
};
const emit = defineEmits<Emits>();
const props = defineProps({
attachments: {
type: Array as PropType<IAttachments[]>,
default: () => [],
},
});
const handleChange = (info: UploadChangeParam) => {
if (info.file.status === 'done') {
const result = info.file.response?.result;
console.log('result: ', result);
}
};
const fileList = ref<IAttachments[]>([]);
watch(
() => props.attachments,
(val) => {
fileList.value = val;
},
{ deep: true },
);
const handleDelete = (id: number) => {
const idx = fileList.value.findIndex((f) => f.id === id);
fileList.value.splice(idx, 1);
emit('update:attachments', fileList.value);
};
const handleAdd = () => {
fileList.value.push({
id: fileList.value.length,
name: '',
location: '',
});
emit('update:attachments', fileList.value);
};
</script>
<style lang="less" scoped>
.attachment-wrapper {
.attachment-item {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
}
</style>

View File

@ -31,40 +31,46 @@
<a-form-item
label="类型"
v-bind="validateInfos.provider"
v-if="formData.type !== 'email'"
v-if="
formData.type !== 'email' &&
formData.type !== 'webhook'
"
>
<RadioCard
:options="msgType"
v-model="formData.provider"
/>
</a-form-item>
<a-form-item
label="绑定配置"
v-bind="validateInfos.configId"
v-if="formData.type !== 'email'"
>
<a-select
v-model:value="formData.configId"
placeholder="请选择绑定配置"
>
<a-select-option
v-for="(item, index) in ROBOT_MSG_TYPE"
:key="index"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
<!-- 钉钉 -->
<template v-if="formData.type === 'dingTalk'">
<template
v-if="formData.provider === 'dingTalkMessage'"
>
<a-form-item
label="AppKey"
v-bind="
validateInfos['configuration.appKey']
"
label="AgentId"
v-bind="validateInfos['template.agentId']"
>
<a-input
v-model:value="
formData.configuration.appKey
"
placeholder="请输入AppKey"
/>
</a-form-item>
<a-form-item
label="AppSecret"
v-bind="
validateInfos['configuration.appSecret']
"
>
<a-input
v-model:value="
formData.configuration.appSecret
formData.template.agentId
"
placeholder="请输入AppSecret"
/>
@ -76,155 +82,340 @@
"
>
<a-form-item
label="webHook"
v-bind="validateInfos['configuration.url']"
label="消息类型"
v-bind="
validateInfos['template.messageType']
"
>
<a-input
<a-select
v-model:value="
formData.configuration.url
formData.template.messageType
"
placeholder="请输入webHook"
/>
placeholder="请选择消息类型"
>
<a-select-option
v-for="(
item, index
) in ROBOT_MSG_TYPE"
:key="index"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
<template
v-if="
formData.template.messageType ===
'markdown'
"
>
<a-form-item
label="标题"
v-bind="
validateInfos[
'template.markdown.title'
]
"
>
<!-- <a-input
v-model:value="
formData.template.markdown
?.title
"
placeholder="请输入标题"
/> -->
</a-form-item>
</template>
<!-- <template
v-if="
formData.template.messageType === 'link'
"
>
<a-form-item
label="标题"
v-bind="
validateInfos['template.link.title']
"
>
<a-input
v-model:value="
formData.template.link?.title
"
placeholder="请输入标题"
/>
</a-form-item>
<a-form-item label="图片链接">
<a-input
v-model:value="
formData.template.link?.picUrl
"
placeholder="请输入图片链接"
/>
</a-form-item>
<a-form-item label="内容链接">
<a-input
v-model:value="
formData.template.link
?.messageUrl
"
placeholder="请输入内容链接"
/>
</a-form-item>
</template> -->
</template>
</template>
<!-- 微信 -->
<template v-if="formData.type === 'weixin'">
<a-form-item
label="corpId"
v-bind="validateInfos['configuration.corpId']"
label="AgentId"
v-bind="validateInfos['template.agentId']"
>
<a-input
v-model:value="
formData.configuration.corpId
"
placeholder="请输入corpId"
v-model:value="formData.template.agentId"
placeholder="请输入agentId"
/>
</a-form-item>
<a-form-item
label="corpSecret"
v-bind="
validateInfos['configuration.corpSecret']
"
>
<a-input
v-model:value="
formData.configuration.corpSecret
"
placeholder="请输入corpSecret"
/>
<a-row :gutter="10">
<a-col :span="12">
<a-form-item label="收信人">
<a-select
v-model:value="
formData.template.toUser
"
placeholder="请选择收信人"
>
<a-select-option
v-for="(
item, index
) in ROBOT_MSG_TYPE"
:key="index"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="收信部门">
<a-select
v-model:value="
formData.template.toParty
"
placeholder="请选择收信部门"
>
<a-select-option
v-for="(
item, index
) in ROBOT_MSG_TYPE"
:key="index"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="标签推送">
<a-select
v-model:value="formData.template.toTag"
placeholder="请选择标签推送"
>
<a-select-option
v-for="(item, index) in ROBOT_MSG_TYPE"
:key="index"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
</template>
<!-- 邮件 -->
<template v-if="formData.type === 'email'">
<a-form-item
label="服务器地址"
v-bind="validateInfos['configuration.host']"
label="标题"
v-bind="validateInfos['template.subject']"
>
<a-space>
<a-input
v-model:value="
formData.configuration.host
"
placeholder="请输入服务器地址"
/>
<a-input-number
v-model:value="
formData.configuration.port
"
/>
<a-checkbox
v-model:value="
formData.configuration.ssl
"
<a-input
v-model:value="formData.template.subject"
placeholder="请输入标题"
/>
</a-form-item>
<a-form-item label="收件人">
<a-select
v-model:value="formData.template.sendTo"
placeholder="请选择收件人"
>
<a-select-option
v-for="(item, index) in ROBOT_MSG_TYPE"
:key="index"
:value="item.value"
>
开启SSL
</a-checkbox>
</a-space>
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="发件人"
v-bind="validateInfos['configuration.sender']"
>
<a-input
v-model:value="
formData.configuration.sender
<a-form-item label="附件信息">
<Attachments
v-model:attachments="
formData.template.attachments
"
placeholder="请输入发件人"
/>
</a-form-item>
<a-form-item
label="用户名"
v-bind="validateInfos['configuration.username']"
>
<a-input
v-model:value="
formData.configuration.username
"
placeholder="请输入用户名"
/>
</a-form-item>
<a-form-item
label="密码"
v-bind="validateInfos['configuration.password']"
>
<a-input
v-model:value="
formData.configuration.password
"
placeholder="请输入密码"
/>
</a-form-item>
</template>
<!-- 语音/短信 -->
<template
v-if="
formData.type === 'voice' ||
formData.type === 'sms'
"
>
<!-- 语音 -->
<template v-if="formData.type === 'voice'">
<a-form-item
label="AccessKeyId"
v-bind="
validateInfos['configuration.accessKeyId']
"
label="类型"
v-bind="validateInfos['template.templateType']"
>
<a-select
v-model:value="
formData.template.templateType
"
placeholder="请选择类型"
>
<a-select-option
v-for="(item, index) in VOICE_TYPE"
:key="index"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-row :gutter="10">
<a-col :span="12">
<a-form-item
label="模板ID"
v-bind="
validateInfos[
'template.templateCode'
]
"
>
<a-input
v-model:value="
formData.template.templateCode
"
placeholder="请输入模板ID"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="被叫号码">
<a-input
v-model:value="
formData.template.calledNumber
"
placeholder="请输入被叫号码"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="被叫显号">
<a-input
v-model:value="
formData.configuration.accessKeyId
formData.template.calledShowNumbers
"
placeholder="请输入AccessKeyId"
placeholder="请输入被叫显号"
/>
</a-form-item>
<a-form-item label="播放次数">
<a-input
v-model:value="formData.template.playTimes"
placeholder="请输入播放次数"
/>
</a-form-item>
<a-form-item
label="Secret"
v-bind="validateInfos['configuration.secret']"
label="模板内容"
v-if="formData.template.templateType === 'tts'"
>
<a-textarea
v-model:value="formData.template.ttsCode"
show-count
:rows="5"
placeholder="内容中的变量将用于阿里云语音验证码"
/>
</a-form-item>
</template>
<!-- 短信 -->
<template v-if="formData.type === 'sms'">
<a-row :gutter="10">
<a-col :span="12">
<a-form-item
label="模板"
v-bind="validateInfos['template.code']"
>
<a-select
v-model:value="
formData.template.code
"
placeholder="请选择模板"
>
<a-select-option
v-for="(
item, index
) in ROBOT_MSG_TYPE"
:key="index"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="收信人">
<a-input
v-model:value="
formData.template.phoneNumber
"
placeholder="请输入收信人"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item
label="签名"
v-bind="validateInfos['template.signName']"
>
<a-input
v-model:value="
formData.configuration.secret
"
placeholder="Secret"
v-model:value="formData.template.signName"
placeholder="请输入签名"
/>
</a-form-item>
</template>
<!-- webhook -->
<template v-if="formData.type === 'webhook'">
<a-form-item
label="Webhook"
v-bind="validateInfos['configuration.url']"
>
<a-input
v-model:value="formData.configuration.url"
placeholder="请输入Webhook"
/>
</a-form-item>
<a-form-item label="请求头">
<!-- <EditTable
v-model:headers="
formData.configuration.headers
<a-form-item label="请求体">
<a-radio-group
v-model:value="
formData.template.contextAsBody
"
/> -->
style="margin-bottom: 20px"
>
<a-radio :value="true">默认</a-radio>
<a-radio :value="false">自定义</a-radio>
</a-radio-group>
<a-textarea
v-model:value="formData.template.body"
placeholder="请求体中的数据来自于发送通知时指定的所有变量"
v-if="formData.template.contextAsBody"
disabled
:rows="5"
/>
<div v-else style="height: 400px">
<MonacoEditor
theme="vs"
v-model:modelValue="
formData.template.body
"
/>
</div>
</a-form-item>
</template>
<a-form-item label="说明">
@ -263,12 +454,15 @@ import { message } from 'ant-design-vue';
import { TemplateFormData } from '../types';
import {
NOTICE_METHOD,
CONFIG_FIELD_MAP,
TEMPLATE_FIELD_MAP,
MSG_TYPE,
ROBOT_MSG_TYPE,
VOICE_TYPE,
} from '@/views/notice/const';
// import EditTable from './components/EditTable.vue';
import configApi from '@/api/notice/config';
import templateApi from '@/api/notice/template';
import Doc from './doc/index';
import MonacoEditor from '@/components/MonacoEditor/index.vue';
import Attachments from './components/Attachments.vue'
const router = useRouter();
const route = useRoute();
@ -290,30 +484,34 @@ const msgType = ref([
//
const formData = ref<TemplateFormData>({
description: '',
template: {},
name: '',
provider: '',
type: NOTICE_METHOD[2].value,
template: {
subject: '',
sendTo: [],
attachments: [],
message: '',
text: '',
},
type: 'email',
provider: 'embedded',
description: '',
variableDefinitions: [],
});
//
watch(
() => formData.value.type,
(val) => {
formData.value.configuration = CONFIG_FIELD_MAP[val];
// formData.value.template = TEMPLATE_FIELD_MAP[val];
msgType.value = MSG_TYPE[val];
formData.value.provider = msgType.value[0].value;
console.log('formData.value.template: ', formData.value.template);
},
);
computed(() => {
console.log('formData.value.type: ', formData.value.type);
Object.assign(
formData.value.template,
TEMPLATE_FIELD_MAP[formData.value.type][formData.value.provider],
);
});
//
const formRules = ref({
type: [{ required: true, message: '请选择通知方式' }],
@ -322,58 +520,23 @@ const formRules = ref({
{ max: 64, message: '最多可输入64个字符' },
],
provider: [{ required: true, message: '请选择类型' }],
configId: [{ required: true, message: '请选择绑定配置' }],
//
'configuration.appKey': [
{ required: true, message: '请输入AppKey' },
{ max: 64, message: '最多可输入64个字符' },
],
'configuration.appSecret': [
{ required: true, message: '请输入AppSecret' },
{ max: 64, message: '最多可输入64个字符' },
],
// 'configuration.url': [{ required: true, message: 'WebHook' }],
'template.agentId': [{ required: true, message: '请输入agentId' }],
'template.messageType': [{ required: true, message: '请选择消息类型' }],
'template.markdown.title': [{ required: true, message: '请输入标题' }],
// 'template.url': [{ required: true, message: 'WebHook' }],
//
'configuration.corpId': [
{ required: true, message: '请输入corpId' },
{ max: 64, message: '最多可输入64个字符' },
],
'configuration.corpSecret': [
{ required: true, message: '请输入corpSecret' },
{ max: 64, message: '最多可输入64个字符' },
],
// /
'configuration.regionId': [
{ required: true, message: '请输入RegionId' },
{ max: 64, message: '最多可输入64个字符' },
],
'configuration.accessKeyId': [
{ required: true, message: '请输入AccessKeyId' },
{ max: 64, message: '最多可输入64个字符' },
],
'configuration.secret': [
{ required: true, message: '请输入Secret' },
{ max: 64, message: '最多可输入64个字符' },
],
// 'template.agentId': [{ required: true, message: 'agentId' }],
//
'configuration.host': [{ required: true, message: '请输入服务器地址' }],
'configuration.sender': [{ required: true, message: '请输入发件人' }],
'configuration.username': [
{ required: true, message: '请输入用户名' },
{ max: 64, message: '最多可输入64个字符' },
],
'configuration.password': [
{ required: true, message: '请输入密码' },
{ max: 64, message: '最多可输入64个字符' },
],
'template.subject': [{ required: true, message: '请输入标题' }],
//
'template.templateType': [{ required: true, message: '请选择类型' }],
'template.templateCode': [{ required: true, message: '请输入模板ID' }],
//
'template.code': [{ required: true, message: '请选择模板' }],
'template.signName': [{ required: true, message: '请输入签名' }],
// webhook
'configuration.url': [
{ required: true, message: '请输入Webhook' },
{
pattern:
/^(((ht|f)tps?):\/\/)?([^!@#$%^&*?.\s-]([^!@#$%^&*?.\s]{0,63}[^!@#$%^&*?.\s])?\.)+[a-z]{2,6}\/?/,
message: 'Webhook需要是一个合法的URL',
},
],
description: [{ max: 200, message: '最多可输入200个字符' }],
});
@ -390,12 +553,12 @@ watch(
);
const getDetail = async () => {
const res = await configApi.detail(route.params.id as string);
const res = await templateApi.detail(route.params.id as string);
// console.log('res: ', res);
formData.value = res.result;
// console.log('formData.value: ', formData.value);
};
getDetail();
// getDetail();
/**
* 表单提交
@ -404,19 +567,19 @@ const btnLoading = ref<boolean>(false);
const handleSubmit = () => {
validate()
.then(async () => {
// console.log('formData.value: ', formData.value);
console.log('formData.value: ', formData.value);
btnLoading.value = true;
let res;
if (!formData.value.id) {
res = await configApi.save(formData.value);
} else {
res = await configApi.update(formData.value);
}
// console.log('res: ', res);
if (res?.success) {
message.success('保存成功');
router.back();
}
// let res;
// if (!formData.value.id) {
// res = await templateApi.save(formData.value);
// } else {
// res = await templateApi.update(formData.value);
// }
// // console.log('res: ', res);
// if (res?.success) {
// message.success('');
// router.back();
// }
btnLoading.value = false;
})
.catch((err) => {

View File

@ -3,6 +3,20 @@
<div class="page-container">通知模板</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import templateApi from '@/api/notice/template';
const getList = async () => {
const res = await templateApi.list({
current: 1,
pageIndex: 0,
pageSize: 12,
sorts: [{ name: 'createTime', order: 'desc' }],
terms: [],
});
console.log('res: ', res);
};
getList();
</script>
<style lang="less" scoped></style>

View File

@ -7,6 +7,7 @@ export interface IHeaders {
interface IAttachments {
location: string;
name: string;
id?: number;
}
interface IVariableDefinitions {
id: string;
@ -16,14 +17,6 @@ interface IVariableDefinitions {
}
export type TemplateFormData = {
name: string;
type: string;
provider: string;
description: string;
id?: string;
creatorId?: string;
createTime?: number;
configId?: string;
template: {
// 钉钉消息
agentId?: string;
@ -41,7 +34,7 @@ export type TemplateFormData = {
text: string;
};
// 微信
agentId?: string;
// agentId?: string;
// message?: string;
toParty?: string;
toUser?: string;
@ -69,6 +62,13 @@ export type TemplateFormData = {
contextAsBody?: boolean;
body?: string;
};
name: string;
type: string;
provider: string;
description: string;
variableDefinitions: IVariableDefinitions[];
id?: string;
creatorId?: string;
createTime?: number;
configId?: string;
};

View File

@ -33,7 +33,7 @@ export const NOTICE_METHOD: INoticeMethod[] = [
},
];
// 消息类型
// 类型
export const MSG_TYPE = {
dingTalk: [
{
@ -93,36 +93,52 @@ export const MSG_TYPE = {
// 配置
export const CONFIG_FIELD_MAP = {
dingTalk: {
appKey: undefined,
appSecret: undefined,
url: undefined,
dingTalkMessage: {
appKey: '',
appSecret: '',
},
dingTalkRobotWebHook: {
url: '',
}
},
weixin: {
corpId: undefined,
corpSecret: undefined,
corpMessage: {
corpId: '',
corpSecret: '',
},
// officialMessage: {},
},
email: {
host: undefined,
port: 25,
ssl: false,
sender: undefined,
username: undefined,
password: undefined,
embedded: {
host: '',
port: 25,
ssl: false,
sender: '',
username: '',
password: '',
}
},
voice: {
regionId: undefined,
accessKeyId: undefined,
secret: undefined,
aliyun: {
regionId: '',
accessKeyId: '',
secret: '',
}
},
sms: {
regionId: undefined,
accessKeyId: undefined,
secret: undefined,
aliyunSms: {
regionId: '',
accessKeyId: '',
secret: '',
}
},
webhook: {
url: undefined,
headers: [],
http: {
url: undefined,
headers: [],
}
},
};
// 模板
@ -187,8 +203,20 @@ export const TEMPLATE_FIELD_MAP = {
},
webhook: {
http: {
contextAsBody: false,
contextAsBody: true,
body: ''
}
},
};
};
// 钉钉机器人-消息类型
export const ROBOT_MSG_TYPE = [
{ label: 'markdown', value: 'markdown' },
{ label: 'text', value: 'text' },
{ label: 'link', value: 'link' },
]
// 语音通知类型
export const VOICE_TYPE = [
{ label: '语音通知', value: 'voice' },
{ label: '语音验证码', value: 'tts' },
]

7952
yarn.lock

File diff suppressed because it is too large Load Diff