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

This commit is contained in:
easy 2023-01-17 18:17:40 +08:00
commit b13bdb30bb
39 changed files with 2328 additions and 752 deletions

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

@ -1,4 +1,5 @@
import server from '@/utils/request'
import { BASE_API_PATH } from '@/utils/variable'
import { DeviceInstance } from '@/views/device/instance/typings'
/**
@ -73,3 +74,28 @@ export const batchUndeployDevice = (data: string[]) => server.put(`/device-insta
* @returns
*/
export const batchDeleteDevice = (data: string[]) => server.put(`/device-instance/batch/_delete`, data)
/**
*
* @param productId id
* @param type
* @returns
*/
export const deviceTemplateDownload = (productId: string, type: string) => `${BASE_API_PATH}/device-instance/${productId}/template.${type}`
/**
*
* @param productId id
* @param type
* @returns
*/
export const deviceImport = (productId: string, fileUrl: string, autoDeploy: boolean) => `${BASE_API_PATH}/device-instance/${productId}/import?fileUrl=${fileUrl}&autoDeploy=${autoDeploy}&:X_Access_Token=${LocalStore.get(TOKEN_KEY)}`
/**
*
* @param productId id
* @param type
* @returns
*/
export const deviceExport = (productId: string, type: string) => `${BASE_API_PATH}/device-instance${!!productId ? '/' + productId : ''}/export.${type}`

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

@ -6,11 +6,11 @@ export const detail = (id: string) => server.get(`/gateway/device/${id}`);
export const getNetworkList = (
networkType: string,
include: string,
data: Object,
params: Object,
) =>
server.get(
`/network/config/${networkType}/_alive?include=${params.include}`,
`/network/config/${networkType}/_alive?include=${include}`,
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

@ -25,7 +25,8 @@ const iconKeys = [
'ImportOutlined',
'ExportOutlined',
'SyncOutlined',
'ExclamationCircleOutlined'
'ExclamationCircleOutlined',
'UploadOutlined'
]
const Icon = (props: {type: string}) => {

View File

@ -228,6 +228,10 @@ const handleClick = () => {
transform: skewX(-45deg);
}
}
:deep(.card-item-content-title) {
cursor: pointer;
}
}
.card-mask {

View File

@ -0,0 +1,36 @@
<template>
<a-space align="end">
<a-radio-group button-style="solid" v-model:value="modelValue.fileType" placeholder="请选择文件格式">
<a-radio-button value="xlsx">xlsx</a-radio-button>
<a-radio-button value="csv">csv</a-radio-button>
</a-radio-group>
<a-checkbox v-model:checked="modelValue.autoDeploy">自动启用</a-checkbox>
</a-space>
</template>
<script lang="ts" setup>
import { PropType } from 'vue'
type Props = {
autoDeploy: boolean,
fileType: 'xlsx' | 'csv'
}
type Emits = {
(e: 'update:modelValue', data: Partial<Props>): void;
};
const emit = defineEmits<Emits>();
const props = defineProps({
//
modelValue: {
type: Object as PropType<Props>,
default: () => {
return {
fileType: 'xlsx',
autoDeploy: false
}
}
},
})
</script>

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

@ -0,0 +1,70 @@
<template>
<a-upload
v-model:file-list="fileList"
name="avatar"
list-type="picture-card"
class="avatar-uploader"
:show-upload-list="false"
action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
:before-upload="beforeUpload"
@change="handleChange"
>
<img v-if="imageUrl" :src="imageUrl" alt="avatar" />
<div v-else>
<loading-outlined v-if="loading"></loading-outlined>
<plus-outlined v-else></plus-outlined>
<div class="ant-upload-text">Upload</div>
</div>
</a-upload>
</template>
<script lang="ts" setup>
import { message, UploadChangeParam, UploadProps } from 'ant-design-vue';
const handleChange = (info: UploadChangeParam) => {
// if (info.file.status === 'uploading') {
// loading.value = true;
// return;
// }
// if (info.file.status === 'done') {
// // Get this url from response in real world.
// getBase64(info.file.originFileObj, (base64Url: string) => {
// imageUrl.value = base64Url;
// loading.value = false;
// });
// }
// if (info.file.status === 'error') {
// loading.value = false;
// message.error('upload error');
// }
};
const beforeUpload = (file: UploadProps['fileList'][number]) => {
// const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
// if (!isJpgOrPng) {
// message.error('You can only upload JPG file!');
// }
// const isLt2M = file.size / 1024 / 1024 < 2;
// if (!isLt2M) {
// message.error('Image must smaller than 2MB!');
// }
// return isJpgOrPng && isLt2M;
};
</script>
<style lang="less" scoped>
.avatar-uploader {
width: 160px;
height: 160px;
padding: 8px;
background-color: rgba(0,0,0,.06);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
:deep(.ant-upload.ant-upload-select-picture-card) {
width: 100%;
height: 100%;
}
}
</style>

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,116 @@
<template>
<a-space align="end">
<a-upload
v-model:fileList="modelValue.upload"
name="file"
:action="FILE_UPLOAD"
:headers="{
'X-Access-Token': LocalStore.get(TOKEN_KEY)
}"
accept=".xlsx,.csv"
:maxCount="1"
:showUploadList="false"
@change="uploadChange"
>
<a-button>
<template #icon><AIcon type="UploadOutlined" /></template>
文件上传
</a-button>
</a-upload>
<div style="margin-left: 20px">
<a-space>
<a @click="downFile('xlsx')">.xlsx</a>
<a @click="downFile('csv')">.csv</a>
</a-space>
</div>
</a-space>
<div style="margin-top: 20px" v-if="importLoading">
<a-badge v-if="flag" status="processing" text="进行中" />
<a-badge v-else status="success" text="已完成" />
<span>总数量{{count}}</span>
<p style="color: red">{{errMessage}}</p>
</div>
</template>
<script lang="ts" setup>
import { FILE_UPLOAD } from '@/api/comm'
import { TOKEN_KEY } from '@/utils/variable';
import { LocalStore } from '@/utils/comm';
import { downloadFile } from '@/utils/utils';
import { deviceImport, deviceTemplateDownload } from '@/api/device/instance'
import { EventSourcePolyfill } from 'event-source-polyfill'
import { message } from 'ant-design-vue';
type Emits = {
(e: 'update:modelValue', data: string[]): void;
};
const emit = defineEmits<Emits>();
const props = defineProps({
//
modelValue: {
type: Array,
default: () => []
},
product: {
type: String,
default: ''
},
file: {
type: Object,
default: () => {
return {
fileType: 'xlsx',
autoDeploy: false,
}
}
}
})
const importLoading = ref<boolean>(false)
const flag = ref<boolean>(false)
const count = ref<number>(0)
const errMessage = ref<string>('')
const downFile = (type: string) => {
downloadFile(deviceTemplateDownload(props.product, type));
}
const submitData = async (fileUrl: string) => {
if (!!fileUrl) {
count.value = 0
errMessage.value = ''
flag.value = true
const autoDeploy = !!props?.file?.autoDeploy || false;
importLoading.value = true
let dt = 0;
const source = new EventSourcePolyfill(deviceImport(props.product, fileUrl, autoDeploy));
source.onmessage = (e: any) => {
const res = JSON.parse(e.data);
if (res.success) {
const temp = res.result.total;
dt += temp;
count.value = dt
} else {
errMessage.value = res.message || '失败'
}
};
source.onerror = (e: { status: number; }) => {
if (e.status === 403) errMessage.value = '暂无权限,请联系管理员'
flag.value = false
source.close();
};
source.onopen = () => {};
} else {
message.error('请先上传文件')
}
}
const uploadChange = async (info: Record<string, any>) => {
if (info.file.status === 'done') {
const resp: any = info.file.response || { result: '' };
await submitData(resp?.result || '');
}
}
</script>

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

@ -174,8 +174,14 @@ const JTable = defineComponent<JTableProps>({
loading.value = true
if(props.request) {
const resp = await props.request({
pageIndex: 0,
pageSize: 12,
...props.defaultParams,
..._params
..._params,
terms: [
...(props.defaultParams?.terms || []),
...(_params?.terms || [])
]
})
if(resp.status === 200){
if(props.type === 'PAGE'){

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

@ -6,6 +6,9 @@ import TitleComponent from "./TitleComponent/index.vue";
import Form from './Form';
import CardBox from './CardBox/index.vue';
import Search from './Search'
import NormalUpload from './NormalUpload/index.vue'
import FileFormat from './FileFormat/index.vue'
import JUpload from './JUpload/index.vue'
export default {
install(app: App) {
@ -16,5 +19,8 @@ export default {
.component('Form', Form)
.component('CardBox', CardBox)
.component('Search', Search)
.component('NormalUpload', NormalUpload)
.component('FileFormat', FileFormat)
.component('JUpload', JUpload)
}
}

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)
}

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

@ -29,6 +29,7 @@ import { queryNoPagingPost } from '@/api/device/product'
import { downloadFile } from '@/utils/utils'
import encodeQuery from '@/utils/encodeQuery'
import { BASE_API_PATH } from '@/utils/variable'
import { deviceExport } from '@/api/device/instance'
const emit = defineEmits(['close'])
const props = defineProps({
@ -58,15 +59,8 @@ watch(
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')
downloadFile(deviceExport(modelRef.product || "", modelRef.fileType),params);
emit('close')
}
const handleCancel = () => {

View File

@ -1,14 +1,67 @@
<template>
<a-modal :maskClosable="false" width="800px" :visible="true" title="导入" @ok="handleOk" @cancel="handleCancel">
123
<a-modal :maskClosable="false" width="800px" :visible="true" title="导入" @ok="handleCancel" @cancel="handleCancel">
<div style="margin-top: 10px">
<a-form :layout="'vertical'">
<a-row>
<a-col span="24">
<a-form-item label="产品" required>
<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-col>
<a-col span="24">
<a-form-item label="文件格式" v-if="modelRef.product">
<FileFormat v-model="modelRef.file" />
</a-form-item>
</a-col>
<a-col span="12">
<a-form-item label="文件上传" v-if="modelRef.product">
<NormalUpload :product="modelRef.product" v-model="modelRef.upload" :file="modelRef.file" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</a-modal>
</template>
<script lang="ts" setup>
const handleOk = () => {
import { queryNoPagingPost } from '@/api/device/product'
import { Form } from 'ant-design-vue';
const emit = defineEmits(['close'])
const props = defineProps({
data: {
type: Object,
default: undefined
}
})
const productList = ref<Record<string, any>[]>([])
const useForm = Form.useForm;
const modelRef = reactive({
product: undefined,
upload: [],
file: {
fileType: 'xlsx',
autoDeploy: false,
}
});
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 handleCancel = () => {
emit('close')
}
</script>

View File

@ -1,16 +1,16 @@
<template>
<a-modal :maskClosable="false" width="800px" :visible="true" title="当前进度" @ok="handleOk" @cancel="handleCancel">
<a-modal :maskClosable="false" width="800px" :visible="true" title="当前进度" @ok="handleCancel" @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 style="color: red">{{errMessage}}</a>
</a-modal>
</template>
<script lang="ts" setup>
import { downloadFile } from '@/utils/utils'
import { EventSourcePolyfill } from 'event-source-polyfill'
const emit = defineEmits(['close'])
const props = defineProps({
@ -29,22 +29,63 @@ const flag = ref<boolean>(false)
const errMessage = ref<string>('')
const isSource = ref<boolean>(false)
const id = ref<string>('')
const handleOk = () => {
emit('close')
}
const source = ref<Record<string, any>>({})
const handleCancel = () => {
emit('close')
}
const getData = () => {
const getData = (api: string) => {
let dt = 0
const _source = new EventSourcePolyfill(api)
source.value = _source
_source.onmessage = (e: any) => {
const res = JSON.parse(e.data);
switch (props.type) {
case 'active':
if (res.success) {
dt += res.total;
count.value = dt
} else {
if (res.source) {
const msg = `${res.source.name}: ${res.message}`;
errMessage.value = msg
id.value = res.source.id
isSource.value = true
} else {
errMessage.value = res.message
}
}
break;
case 'sync':
dt += res;
count.value = dt
break;
case 'import':
if (res.success) {
const temp = res.result.total;
dt += temp;
count.value = dt
} else {
errMessage.value = res.message
}
break;
default:
break;
}
};
_source.onerror = () => {
flag.value = false
_source.close();
};
_source.onopen = () => {};
}
watch(() => props.api,
() => {
getData()
(newValue) => {
if(newValue) {
getData(newValue)
}
},
{deep: true, immediate: true}
)

View File

@ -0,0 +1,70 @@
<template>
<a-modal :maskClosable="false" width="650px" :visible="true" title="新增" @ok="handleCancel" @cancel="handleCancel">
<div style="margin-top: 10px">
<a-form :layout="'vertical'">
<a-row type="flex">
<a-col flex="180px">
<a-form-item required>
<JUpload />
</a-form-item>
</a-col>
<a-col flex="auto">
<a-form-item label="ID">
<a-input v-model:value="modelRef.id" placeholder="请输入ID" />
</a-form-item>
<a-form-item label="名称" required>
<a-input v-model:value="modelRef.name" placeholder="请输入名称" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="产品" required>
<a-select showSearch v-model:value="modelRef.productId" 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-textarea v-model:value="modelRef.describe" placeholder="请输入说明" />
</a-form-item>
</a-form>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { queryNoPagingPost } from '@/api/device/product'
import { Form } from 'ant-design-vue';
const emit = defineEmits(['close', 'save'])
const props = defineProps({
data: {
type: Object,
default: undefined
}
})
const productList = ref<Record<string, any>[]>([])
const useForm = Form.useForm;
const modelRef = reactive({
productId: undefined,
id: '',
name: '',
describe: '',
photoUrl: ''
});
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 handleCancel = () => {
emit('close')
}
</script>

View File

@ -73,7 +73,7 @@
</slot>
</template>
<template #content>
<h3 @click="handleView(slotProps.id)">{{ slotProps.name }}</h3>
<h3 class="card-item-content-title" @click.stop="handleView(slotProps.id)">{{ slotProps.name }}</h3>
<a-row>
<a-col :span="12">
<div class="card-item-content-text">设备类型</div>
@ -151,6 +151,7 @@
<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" />
<Save v-if="visible" :data="current" />
</template>
<script setup lang="ts">
@ -161,13 +162,15 @@ 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 Save from './Save/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 params = ref<Record<string, any>>({})
const _selectedRowKeys = ref<string[]>([])
const importVisible = ref<boolean>(false)
const exportVisible = ref<boolean>(false)
const visible = ref<boolean>(false)
const current = ref<Record<string, any>>({})
const operationVisible = ref<boolean>(false)
const api = ref<string>('')
@ -220,9 +223,9 @@ const columns = [
}
]
const paramsFormat = (config: any, _terms: any, name?: string) => {
const paramsFormat = (config: Record<string, any>, _terms: Record<string, any>, name?: string) => {
if (config?.terms && Array.isArray(config.terms) && config?.terms.length > 0) {
(config?.terms || []).map((item: any, index: number) => {
(config?.terms || []).map((item: Record<string, any>, index: number) => {
if (item?.type) {
_terms[`${name ? `${name}.` : ''}terms[${index}].type`] = item.type;
}
@ -237,28 +240,33 @@ const paramsFormat = (config: any, _terms: any, name?: string) => {
}
}
const handleParams = (config: any) => {
const _terms: any = {};
const handleParams = (config: Record<string, any>) => {
const _terms: Record<string, any> = {};
paramsFormat(config, _terms);
const url = new URLSearchParams();
Object.keys(_terms).forEach((key) => {
url.append(key, _terms[key]);
});
return url.toString();
if(Object.keys(_terms._value).length && Object.keys(_terms).length) {
const url = new URLSearchParams();
Object.keys(_terms).forEach((key) => {
url.append(key, _terms[key]);
});
return url.toString();
} else {
return ''
}
}
/**
* 新增
*/
const handleAdd = () => {
message.warn('新增')
visible.value = true
current.value = {}
}
/**
* 查看
*/
const handleView = (dt: any) => {
// message.warn('')
const handleView = (id: string) => {
message.warn(id + '暂未开发')
}
const getActions = (data: Partial<Record<string, any>>, type: 'card' | 'table'): ActionsType[] => {
@ -272,7 +280,7 @@ const getActions = (data: Partial<Record<string, any>>, type: 'card' | 'table'):
},
icon: 'EyeOutlined',
onClick: () => {
handleView(data)
handleView(data.id)
}
},
{
@ -283,7 +291,8 @@ const getActions = (data: Partial<Record<string, any>>, type: 'card' | 'table'):
},
icon: 'EditOutlined',
onClick: () => {
message.warn('edit')
visible.value = true
current.value = data
}
},
{
@ -356,14 +365,14 @@ const handleClick = (dt: any) => {
const activeAllDevice = () => {
type.value = 'active'
const activeAPI = `/${BASE_API_PATH}/device-instance/deploy?:X_Access_Token=${LocalStore.get(TOKEN_KEY)}&${handleParams(params)}`;
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)}`;
const syncAPI = `${BASE_API_PATH}/device-instance/state/_sync?:X_Access_Token=${LocalStore.get(TOKEN_KEY)}&${handleParams(params)}`;
api.value = syncAPI
operationVisible.value = true
}

View File

@ -12,10 +12,10 @@
<div>
<a-form
:model="formState"
ref="formRef1"
name="basic"
autocomplete="off"
layout="vertical"
@finish="onFinish"
>
<a-row :gutter="[24, 24]">
<a-col :span="12">
@ -25,7 +25,11 @@
:rules="[
{
required: true,
message: '请输入SIP 域!',
message: '请输入SIP 域',
},
{
max: 64,
message: '最大可输入64个字符',
},
]"
>
@ -42,7 +46,11 @@
:rules="[
{
required: true,
message: '请输入SIP ID!',
message: '请输入SIP ID',
},
{
max: 64,
message: '最大可输入64个字符',
},
]"
>
@ -59,7 +67,7 @@
:rules="[
{
required: true,
message: '请选择集群!',
message: '请选择集群',
},
]"
>
@ -88,65 +96,104 @@
<a-radio :value="false">独立配置</a-radio>
</a-radio-group>
</a-form-item>
<div v-if="formState.shareCluster">
<div v-if="formState.shareCluster" class="form-item1">
<a-row :gutter="[24, 24]">
<a-col :span="12">
<a-col :span="6">
<a-form-item
label="SIP 地址"
name="sip"
:name="['hostPort', 'host']"
:rules="[
{
required: true,
message: '请选择端口!',
message: '请选择SIP地址',
},
]"
>
<a-input-group compact>
<a-select
v-model:value="formState.sip1"
style="width: 50%"
:disabled="true"
<a-select
v-model:value="
formState.hostPort.host
"
style="width: 105%"
:disabled="true"
show-search
:filter-option="filterOption"
>
<a-select-option value="0.0.0.0"
>0.0.0.0</a-select-option
>
<a-select-option value="0.0.0.0"
>0.0.0.0</a-select-option
>
</a-select>
<a-select
v-model:value="formState.sip"
:options="sipList"
style="width: 50%"
placeholder="请选择端口"
/>
</a-input-group>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-col :span="6">
<a-form-item
label="公网 Host"
name="public"
:name="['hostPort', 'port']"
:rules="[
{
required: true,
message: '请选择端口!',
message: '请选择端口',
},
]"
>
<a-input-group compact>
<a-input
style="width: 50%"
v-model:value="
formState.public1
"
placeholder="请输入IP地址"
/>
<a-input-number
style="width: 50%"
placeholder="请输入端口"
v-model:value="formState.public"
:min="1"
:max="65535"
/>
</a-input-group>
<div class="form-label"></div>
<a-select
v-model:value="
formState.hostPort.port
"
:options="sipList"
placeholder="请选择端口"
allowClear
show-search
:filter-option="filterOption"
/>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item
label="公网 Host"
:name="['hostPort', 'publicHost']"
:rules="[
{
required: true,
message: '请输入IP地址',
},
{
pattern:
/^([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])$/,
message: '请输入正确的IP地址',
},
]"
>
<a-input
style="width: 105%"
v-model:value="
formState.hostPort.publicHost
"
placeholder="请输入IP地址"
/>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item
:name="['hostPort', 'publicPort']"
:rules="[
{
required: true,
message: '输入端口',
},
]"
>
<div class="form-label"></div>
<a-input-number
style="width: 100%"
placeholder="请输入端口"
v-model:value="
formState.hostPort.publicPort
"
:min="1"
:max="65535"
/>
</a-form-item>
</a-col>
</a-row>
@ -158,50 +205,56 @@
layout="vertical"
name="dynamic_form_nest_item"
:model="dynamicValidateForm"
@finish="onFinish2"
>
<div
v-for="(
user, index
) in dynamicValidateForm.users"
:key="user.id"
cluster, index
) in dynamicValidateForm.cluster"
:key="cluster.id"
>
<a-collapse v-model:activeKey="activeKey">
<a-collapse-panel
:key="user.id"
:key="cluster.id"
:header="`#${index + 1}.节点`"
>
<template #extra>
<delete-outlined
@click="removeUser(user)"
@click="removeCluster(cluster)"
/>
</template>
<a-row :gutter="[24, 24]">
<a-col :span="8">
<a-form-item
label="节点名称"
:name="[
'users',
'cluster',
index,
'first',
'clusterNodeId',
]"
>
<div class="form-label">
节点名称
</div>
<a-select
v-model:value="
user.first
cluster.clusterNodeId
"
:options="clustersList"
placeholder="请选择节点名称"
allowClear
show-search
:filter-option="
filterOption
"
>
</a-select>
</a-form-item>
</a-col>
<a-col :span="7">
<a-col :span="4">
<a-form-item
:name="[
'users',
'cluster',
index,
'last',
'host',
]"
:rules="{
required: true,
@ -209,13 +262,10 @@
'请选择SIP 地址',
}"
>
<div>
<div class="form-label">
SIP 地址
<span
style="
color: red;
margin: 0 4px 0 -2px;
"
class="form-label-required"
>*</span
>
<a-tooltip>
@ -227,46 +277,84 @@
<question-circle-outlined />
</a-tooltip>
</div>
<a-input-group compact>
<a-select
v-model:value="
user.last
"
style="width: 50%"
:options="sipList"
>
</a-select>
<a-select
v-model:value="
user.last1
"
:options="sipList"
style="width: 50%"
placeholder="请选择端口"
/>
</a-input-group>
<a-select
v-model:value="
cluster.host
"
:options="sipListOption"
placeholder="请选择IP地址"
allowClear
show-search
:filter-option="
filterOption
"
style="width: 110%"
@change="
handleChangeForm2Sip(
index,
)
"
>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-col :span="4">
<a-form-item
:name="[
'users',
'cluster',
index,
'last2',
'port',
]"
:rules="{
required: true,
message:
'请输入公网 Host',
message: '请选择端口',
}"
>
<div>
<div
class="form-label"
></div>
<a-select
v-model:value="
cluster.port
"
:options="
sipListIndex[index]
"
placeholder="请选择端口"
allowClear
show-search
:filter-option="
filterOption
"
/>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item
:name="[
'cluster',
index,
'publicHost',
]"
:rules="[
{
required: true,
message:
'请输入公网 Host',
},
{
pattern:
/^([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])$/,
message:
'请输入正确的IP地址',
},
]"
>
<div class="form-label">
公网 Host
<span
style="
color: red;
margin: 0 4px 0 -2px;
"
class="form-label-required"
>*</span
>
<a-tooltip>
@ -278,24 +366,44 @@
<question-circle-outlined />
</a-tooltip>
</div>
<a-input-group compact>
<a-input
style="width: 50%"
v-model:value="
user.last2
"
placeholder="请输入IP地址"
/>
<a-input-number
style="width: 50%"
placeholder="请输入端口"
v-model:value="
user.last3
"
:min="1"
:max="65535"
/>
</a-input-group>
<a-input
style="width: 110%"
v-model:value="
cluster.publicHost
"
placeholder="请输入IP地址"
allowClear
/>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item
:name="[
'cluster',
index,
'publicPort',
]"
:rules="[
{
required: true,
message:
'请输入端口',
},
]"
>
<div
class="form-label"
></div>
<a-input-number
style="width: 100%"
placeholder="请输入端口"
v-model:value="
cluster.publicPort
"
:min="1"
:max="65535"
/>
</a-form-item>
</a-col>
</a-row>
@ -307,17 +415,12 @@
style="margin-top: 10px"
type="dashed"
block
@click="addUser"
@click="addCluster"
>
<PlusOutlined />
新增
</a-button>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit"
>Submit</a-button
>
</a-form-item>
</a-form>
</div>
</div>
@ -333,11 +436,7 @@
<a-col :span="12">
<title-component data="基本信息" />
<div>
<a-form
ref="formRef"
:model="form"
layout="vertical"
>
<a-form :model="form" layout="vertical">
<a-form-item
label="名称"
v-bind="validateInfos.name"
@ -380,110 +479,13 @@
<div class="config-right-item-title">
消息协议
</div>
<div class="config-right-item-context">
<div>
{{
procotolList.find(
(i) => i.id === procotolCurrent,
).name
provider?.id === 'fixed-media'
? 'URL'
: 'SIP'
}}
</div>
<div
class="config-right-item-context"
v-if="config.document"
>
<Markdown :source="config.document" />
</div>
</div>
<div
class="config-right-item"
v-if="
networkList.find(
(i) => i.id === networkCurrent,
) &&
(
networkList.find(
(i) => i.id === networkCurrent,
).addresses || []
).length > 0
"
>
<div class="config-right-item-title">
网络组件
</div>
<div
v-for="i in (networkList.find(
(i) => i.id === networkCurrent,
) &&
networkList.find(
(i) => i.id === networkCurrent,
).addresses) ||
[]"
:key="i.address"
>
<a-badge
:color="
i.health === -1
? 'red'
: 'green'
"
:text="i.address"
/>
</div>
</div>
<div
class="config-right-item"
v-if="
config.routes &&
config.routes.length > 0
"
>
<div class="config-right-item-title">
{{
data.provider ===
'mqtt-server-gateway' ||
data.provider ===
'mqtt-client-gateway'
? 'topic'
: 'URL信息'
}}
</div>
<a-table
:pagination="false"
:rowKey="generateUUID()"
:data-source="config.routes || []"
bordered
:columns="columnsMQTT"
:scroll="{ y: 300 }"
>
<template
#bodyCell="{ column, text, record }"
>
<template
v-if="
column.dataIndex ===
'stream'
"
>
<span
v-if="
record.upstream &&
record.downstream
"
>上行下行</span
>
<span
v-else-if="record.upstream"
>上行</span
>
<span
v-else-if="
record.downstream
"
>下行</span
>
</template>
</template>
</a-table>
</div>
</div>
</a-col>
@ -492,14 +494,10 @@
</div>
</div>
<div class="steps-action">
<a-button
v-if="[0, 1].includes(current)"
type="primary"
@click="next"
>
<a-button v-if="[0].includes(current)" @click="next">
下一步
</a-button>
<a-button v-if="current === 2" type="primary" @click="saveData">
<a-button v-if="current === 1" type="primary" @click="saveData">
保存
</a-button>
<a-button v-if="current > 0" style="margin-left: 8px" @click="prev">
@ -512,47 +510,29 @@
<script lang="ts" setup name="AccessNetwork">
import { message, Form } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import Markdown from 'vue3-markdown-it';
import { QuestionCircleOutlined } from '@ant-design/icons-vue';
import { getResourcesCurrent, getClusters } from '@/api/link/accessConfig';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { update, save } from '@/api/link/accessConfig';
interface User {
first: string;
last1: string;
last: string;
last2: string;
last3: string;
interface Form2 {
clusterNodeId: string;
port: string;
host: string;
publicPort: string;
publicHost: string;
id: number;
}
const activeKey = ref([]);
const formRef2 = ref<FormInstance>();
const dynamicValidateForm = reactive<{ users: User[] }>({
users: [],
});
const removeUser = (item: User) => {
let index = dynamicValidateForm.users.indexOf(item);
if (index !== -1) {
dynamicValidateForm.users.splice(index, 1);
}
};
const addUser = () => {
dynamicValidateForm.users.push({
first: '',
last1: '',
last: '',
last2: '',
last3: '',
id: Date.now(),
});
};
const onFinish2 = (values) => {
console.log('Received values of form:', values);
console.log('dynamicValidateForm.users:', dynamicValidateForm.users);
};
interface FormState {
domain: string;
sipId: string;
shareCluster: boolean;
hostPort: {
port: string;
host: string;
publicPort: string;
publicHost: string;
};
}
const props = defineProps({
provider: {
@ -565,9 +545,14 @@ const props = defineProps({
},
});
const route = useRoute();
const id = route.query.id;
const activeKey: any = ref([]);
const clientHeight = document.body.clientHeight;
const formRef = ref<FormInstance>();
const formRef1 = ref<FormInstance>();
const formRef2 = ref<FormInstance>();
const useForm = Form.useForm;
const current = ref(0);
@ -577,10 +562,70 @@ const form = reactive({
name: '',
description: '',
});
const formState = reactive<FormState>({
domain: '',
sipId: '',
shareCluster: true,
hostPort: {
port: '',
host: '0.0.0.0',
publicPort: '',
publicHost: '',
},
});
let params = {
configuration: {},
};
let sipListConst: any = [];
const sipListOption = ref([]);
const sipList = ref([]);
const sipListIndex: any = ref([]);
const clustersList = ref([]);
const dynamicValidateForm = reactive<{ cluster: Form2[] }>({
cluster: [],
});
const removeCluster = (item: Form2) => {
let index = dynamicValidateForm.cluster.indexOf(item);
if (index !== -1) {
dynamicValidateForm.cluster.splice(index, 1);
}
};
const addCluster = () => {
const id = Date.now();
dynamicValidateForm.cluster.push({
clusterNodeId: '',
port: '',
host: '',
publicPort: '',
publicHost: '',
id,
});
activeKey.value = [...activeKey.value, id.toString()];
};
const filterOption = (input: string, option: any) => {
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0;
};
const handleChangeForm2Sip = (index: number) => {
dynamicValidateForm.cluster[index].port = '';
const value = dynamicValidateForm.cluster[index].host;
sipListIndex.value[index] = sipListConst
.find((i: any) => i.host === value)
?.portList.map((i: any) => {
return {
value: JSON.stringify({
host: value,
port: i.port,
}),
label: `${i.transports.join('/')} (${i.port})`,
};
});
};
const { resetFields, validate, validateInfos } = useForm(
form,
reactive({
@ -592,61 +637,81 @@ const { resetFields, validate, validateInfos } = useForm(
);
const saveData = () => {
validate()
.then(async (values) => {
console.log(333, values);
})
.catch((err) => {});
validate().then(async (values) => {
params = {
...params,
...values,
provider: 'gb28181-2016',
transport: 'SIP',
channel: 'gb28181',
};
const resp = !!id
? await update({ ...params, id })
: await save(params);
if (resp.status === 200) {
message.success('操作成功!');
// if (params.get('save')) {
// if ((window as any).onTabSaveSuccess) {
// if (resp.result) {
// (window as any).onTabSaveSuccess(resp.result);
// setTimeout(() => window.close(), 300);
// }
// }
// } else {
history.back();
// }
}
});
};
const next = async () => {
console.log(22, current.value);
let data1: any = await formRef1.value?.validate();
if (data1.hostPort?.port) {
const port = JSON.parse(data1.hostPort.port).port;
data1.hostPort.port = port;
}
if (!data1?.shareCluster) {
let data2 = await formRef2.value?.validate();
if (data2 && data2?.cluster) {
data2.cluster.forEach((i: any) => {
i.enabled = true;
i.port = JSON.parse(i.port).port;
});
data1 = {
...data1,
...data2,
};
}
}
current.value = current.value + 1;
params.configuration = data1;
};
const prev = () => {
current.value = current.value - 1;
};
interface FormState {
domain: string;
sipId: string;
shareCluster: boolean;
sip1: string;
sip: string;
public1: string;
public: string;
}
const formState = reactive<FormState>({
domain: '',
sipId: '',
shareCluster: true,
sip1: '0.0.0.0',
sip: '',
public1: '',
public: '',
});
const onFinish = (values: any) => {
console.log('Success:', values);
};
onMounted(() => {
getResourcesCurrent().then((resp) => {
if (resp.status === 200) {
const data: any = resp.result.find((i) => i.host === '0.0.0.0');
const list = data.portList.map((i) => {
const label = `${i.transports.join('/')} (${i.port})`;
const value = {
host: '0.0.0.0',
port: i.port,
};
return {
value: JSON.stringify(value),
label,
};
});
sipList.value = list;
sipListConst = resp.result;
sipListOption.value = sipListConst.map((i) => ({
value: i.host,
label: i.host,
}));
sipList.value = sipListConst
.find((i) => i.host === '0.0.0.0')
?.portList.map((i) => {
return {
value: JSON.stringify({
host: '0.0.0.0',
port: i.port,
}),
label: `${i.transports.join('/')} (${i.port})`,
};
});
}
});
console.log(1);
getClusters().then((resp) => {
if (resp.status === 200) {
@ -662,11 +727,7 @@ onMounted(() => {
watch(
current,
(v) => {
// if (props.provider.channel !== 'child-device') {
// stepCurrent.value = v;
// } else {
// stepCurrent.value = v - 1;
// }
stepCurrent.value = v;
},
{
deep: true,
@ -745,4 +806,17 @@ watch(
}
}
}
.form-item1 {
background-color: #f6f6f6;
padding: 10px;
}
.form-label {
height: 30px;
padding-bottom: 8px;
.form-label-required {
color: red;
margin: 0 4px 0 -2px;
}
}
</style>

View File

@ -78,11 +78,14 @@
import { message, Form } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import GB28181 from './GB28181.vue';
import { update, save } from '@/api/link/accessConfig';
interface FormState {
name: string;
description: string;
}
const route = useRoute();
const id = route.query.id;
const props = defineProps({
provider: {
@ -96,14 +99,32 @@ const props = defineProps({
});
const channel = ref(props.provider.channel);
console.log(211, channel.value, props);
const formState = reactive<FormState>({
name: '',
description: '',
});
const onFinish = (values: any) => {
console.log('Success:', values);
const onFinish = async (values: any) => {
const params = {
...values,
provider: 'fixed-media',
transport: 'URL',
channel: 'fixed-media',
};
const resp = !!id ? await update({ ...params, id }) : await save(params);
if (resp.status === 200) {
message.success('操作成功!');
// if (params.get('save')) {
// if ((window as any).onTabSaveSuccess) {
// if (resp.result) {
// (window as any).onTabSaveSuccess(resp.result);
// setTimeout(() => window.close(), 300);
// }
// }
// } else {
history.back();
// }
}
};
</script>

View File

@ -295,11 +295,7 @@
</div>
</div>
<div class="steps-action">
<a-button
v-if="[0, 1].includes(current)"
type="primary"
@click="next"
>
<a-button v-if="[0, 1].includes(current)" @click="next">
下一步
</a-button>
<a-button v-if="current === 2" type="primary" @click="saveData">
@ -588,8 +584,12 @@ const { resetFields, validate, validateInfos } = useForm(
}),
);
const queryNetworkList = async (id: string, params: object, data = {}) => {
const resp = await getNetworkList(NetworkTypeMapping.get(id), data, params);
const queryNetworkList = async (id: string, include: string, data = {}) => {
const resp = await getNetworkList(
NetworkTypeMapping.get(id),
include,
data,
);
if (resp.status === 200) {
networkList.value = resp.result;
}
@ -623,9 +623,7 @@ const addNetwork = () => {
tab.onTabSaveSuccess = (value) => {
if (value.success) {
networkCurrent.value = value.result.id;
queryNetworkList(props.provider?.id, {
include: networkCurrent.value || '',
});
queryNetworkList(props.provider?.id, networkCurrent.value || '');
}
};
};
@ -648,20 +646,14 @@ const checkedChange = (id: string) => {
};
const networkSearch = (value: string) => {
queryNetworkList(
props.provider.id,
{
include: networkCurrent.value || '',
},
{
terms: [
{
column: 'name$LIKE',
value: `%${value}%`,
},
],
},
);
queryNetworkList(props.provider.id, networkCurrent.value || '', {
terms: [
{
column: 'name$LIKE',
value: `%${value}%`,
},
],
});
};
const procotolChange = (id: string) => {
if (!props.data.id) {
@ -862,9 +854,7 @@ onMounted(() => {
procotolCurrent.value = props.data.protocol;
current.value = 0;
networkCurrent.value = props.data.channelId;
queryNetworkList(props.provider.id, {
include: networkCurrent.value,
});
queryNetworkList(props.provider.id, networkCurrent.value);
procotolCurrent.value = props.data.protocol;
steps.value = ['网络组件', '消息协议', '完成'];
} else {
@ -875,9 +865,7 @@ onMounted(() => {
} else {
if (props.provider?.id) {
if (props.provider.channel !== 'child-device') {
queryNetworkList(props.provider.id, {
include: '',
});
queryNetworkList(props.provider.id, '');
steps.value = ['网络组件', '消息协议', '完成'];
current.value = 0;
} else {

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' },
]