Merge pull request #58 from jetlinks/dev-hub

feat: 采集器 左侧树全部功能,右侧卡片列表以及部分表单
This commit is contained in:
胡彪 2023-03-08 09:39:04 +08:00 committed by GitHub
commit d200ac9ace
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1801 additions and 98 deletions

View File

@ -2,3 +2,22 @@ import server from '@/utils/request';
export const queryCollector = (data: any) =>
server.post(`/data-collect/collector/_query/no-paging?paging=false`, data);
export const queryChannelNoPaging = () =>
server.post(`/data-collect/channel/_query/no-paging?paging=false`, {});
export const save = (data: any) => server.post(`/data-collect/collector`, data);
export const update = (id: string, data: any) =>
server.put(`/data-collect/collector/${id}`, data);
export const remove = (id: string) =>
server.remove(`/data-collect/collector/${id}`);
export const queryPoint = (data: any) =>
server.post(`/data-collect/point/_query`, data);
export const _validateField = (id: string, data?: any) =>
server.get(`/data-collect/point/${id}/_validate`, data);
export const queryCodecProvider = () => server.get(`/things/collector/codecs`);

View File

@ -75,7 +75,8 @@ const iconKeys = [
'LogoutOutlined',
'ReadIconOutlined',
'CloudDownloadOutlined',
'PauseCircleOutlined',
'PauseCircleOutlined',,
'FormOutlined',
]
const Icon = (props: {type: string}) => {

View File

@ -0,0 +1,472 @@
<template lang="">
<j-modal
:title="data.id ? '编辑' : '新增'"
:visible="true"
width="700px"
@cancel="handleCancel"
>
<j-form
class="form"
layout="vertical"
:model="formData"
name="basic"
autocomplete="off"
>
<j-form-item
label="点位名称"
name="name"
:rules="[
{
required: true,
message: '请输入点位名称',
},
{
max: 64,
message: '最多可输入64个字符',
},
]"
>
<j-input
placeholder="请输入点位名称"
v-model:value="formData.name"
/>
</j-form-item>
<j-form-item
label="功能码"
:name="['configuration', 'function']"
:rules="[
{
required: true,
message: '请选择功能码',
},
]"
>
<j-select
style="width: 100%"
v-model:value="formData.configuration.function"
:options="[
{ label: '01线圈寄存器', value: 'Coils' },
{ label: '03保存寄存器', value: 'HoldingRegisters' },
{ label: '04输入寄存器', value: 'DiscreteInputs' },
]"
placeholder="请选择所功能码"
allowClear
show-search
:filter-option="filterOption"
/>
</j-form-item>
<j-form-item
label="地址"
:name="['configuration', 'parameter', 'address']"
:rules="[
{
required: true,
message: '请输入地址',
},
{
pattern: regOnlyNumber,
message: '请输入0-255之间的正整数',
},
{
validator: checkAddress,
trigger: 'blur',
},
]"
>
<j-input-number
style="width: 100%"
placeholder="请输入地址"
v-model:value="formData.configuration.parameter.address"
:min="0"
:max="255"
/>
</j-form-item>
<j-form-item
label="寄存器数量"
:name="['configuration', 'parameter', 'quantity']"
:rules="[
{
required: true,
message: '请输入寄存器数量',
},
{
pattern: regOnlyNumber,
message: '请输入1-255之间的正整数',
},
]"
>
<j-input-number
style="width: 100%"
placeholder="请输入寄存器数量"
v-model:value="formData.configuration.parameter.quantity"
:min="1"
:max="255"
/>
</j-form-item>
<j-form-item
label="数据类型"
:name="['configuration', 'codec', 'provider']"
:rules="[
{
required: true,
message: '请选择数据类型',
},
]"
>
<j-select
style="width: 100%"
v-model:value="formData.configuration.codec.provider"
:options="providerList"
placeholder="请选择数据类型"
allowClear
show-search
:filter-option="filterOption"
/>
</j-form-item>
<j-form-item
label="缩放因子"
:name="[
'configuration',
'codec',
'configuration',
'scaleFactor',
]"
:rules="[
{
required: true,
message: '请输入缩放因子',
},
]"
>
<j-input
placeholder="请输入缩放因子"
v-model:value="
formData.configuration.codec.configuration.scaleFactor
"
/>
</j-form-item>
<j-form-item
label="访问类型"
:name="['accessModes']"
:rules="[
{
required: true,
message: '请选择访问类型',
},
]"
>
<RadioCard
layout="horizontal"
:checkStyle="true"
:options="[
{ label: '读', value: 'read' },
{ label: '写', value: 'write' },
]"
v-model="formData.accessModes"
/>
<!-- <j-card-select
v-model:value="formData.accessModes"
:options="[
{
label: '读',
value: 'read',
iconUrl:
'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
},
{
label: '写',
value: 'write',
iconUrl:
'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
},
]"
multiple
/> -->
</j-form-item>
<!-- <j-form-item label="非标准协议写入配置" :name="['nspwc']"> -->
<j-form-item :name="['nspwc']">
<span>非标准协议写入配置</span>
<j-switch v-model:checked="formData.nspwc" />
</j-form-item>
<j-form-item
label="是否写入数据区长度"
:name="['configuration', 'parameter', 'writeByteCount']"
:rules="[
{
required: true,
message: '请选择是否写入数据区长度',
},
]"
>
<RadioCard
layout="horizontal"
:checkStyle="true"
:options="[
{ label: '是', value: true },
{ label: '否', value: false },
]"
v-model="formData.configuration.parameter.writeByteCount"
/>
</j-form-item>
<j-form-item
label="自定义数据区长度byte"
:name="['configuration', 'parameter', 'byteCount']"
:rules="[
{
required: true,
message: '请输入自定义数据区长度byte',
},
]"
>
<j-input
placeholder="请输入自定义数据区长度byte"
v-model:value="formData.configuration.parameter.byteCount"
/>
</j-form-item>
<j-form-item
label="采集频率"
:name="['configuration', 'interval']"
:rules="[
{
required: true,
message: '请输入采集频率',
},
]"
>
<j-input-number
style="width: 100%"
placeholder="请输入采集频率"
v-model:value="formData.configuration.interval"
:min="1"
/>
</j-form-item>
<a-form-item label="" :name="['features']">
<a-checkbox-group v-model:value="formData.features">
<a-checkbox value="changedOnly" name="type"
>只推送变化的数据</a-checkbox
>
</a-checkbox-group>
</a-form-item>
<j-form-item label="说明" :name="['description']">
<j-textarea
placeholder="请输入说明"
v-model:value="formData.description"
:maxlength="200"
:rows="3"
showCount
/>
</j-form-item>
</j-form>
<template #footer>
<j-button key="back" @click="handleCancel">取消</j-button>
<PermissionButton
key="submit"
type="primary"
:loading="loading"
@click="handleOk"
style="margin-left: 8px"
:hasPermission="`DataCollect/Collector:${
id ? 'update' : 'add'
}`"
>
确认
</PermissionButton>
</template>
</j-modal>
</template>
<script lang="ts" setup>
import { Form } from 'ant-design-vue';
import {
save,
update,
_validateField,
queryCodecProvider,
} from '@/api/data-collect/collector';
import { Store } from 'jetlinks-store';
const loading = ref(false);
const useForm = Form.useForm;
const channelListAll = ref();
const channelList = ref();
const visibleEndian = ref(false);
const visibleUnitId = ref(false);
const providerList = ref([]);
const props = defineProps({
data: {
type: Object,
default: () => {},
},
});
const emit = defineEmits(['change']);
const id = props.data.id;
const treeId = props.data.treeId;
const formData = ref({
name: '',
configuration: {
function: undefined,
interval: '',
parameter: {
address: '',
quantity: '',
writeByteCount: '',
byteCount: '',
},
codec: {
provider: '',
configuration: {
scaleFactor: '',
},
},
},
accessModes: undefined,
nspwc: '',
byte: '',
features: '',
description: '',
});
const regOnlyNumber = new RegExp(/^\d+$/);
const checkAddress = (_rule: Rule, value: string): Promise<any> =>
new Promise(async (resolve, reject) => {
if (value) {
const res = await _validateField(props.data.treeId, {
pointKey: value,
});
return res.result.passed ? resolve('') : reject(res.result.reason);
}
});
// const { resetFields, validate, validateInfos } = useForm(
// formData,
// reactive({
// channelId: [
// { required: true, message: '', trigger: 'blur' },
// ],
// name: [
// { required: true, message: '', trigger: 'blur' },
// { max: 64, message: '64' },
// ],
// 'configuration.unitId': [
// { required: true, message: '', trigger: 'blur' },
// {
// pattern: regOnlyNumber,
// message: '0-255',
// },
// ],
// 'circuitBreaker.type': [
// { required: true, message: '', trigger: 'blur' },
// ],
// 'configuration.endian': [
// { required: true, message: '', trigger: 'blur' },
// ],
// description: [{ max: 200, message: '200' }],
// }),
// );
const onSubmit = () => {
// validate()
// .then(async (res) => {
// const { provider, name } = channelListAll.value.find(
// (item) => item.id === formData.value.channelId,
// );
// const params = {
// ...toRaw(formData.value),
// provider,
// channelName: name,
// };
// loading.value = true;
// const response = !id
// ? await save(params)
// : await update(id, { ...props.data, ...params });
// if (response.status === 200) {
// emit('change', true);
// }
// loading.value = false;
// })
// .catch((err) => {
// loading.value = false;
// });
};
const getTypeTooltip = (value: string) =>
value === 'LowerFrequency'
? '连续20次异常降低连接频率至原有频率的1/10重试间隔不超过1分钟故障处理后自动恢复至设定连接频率'
: value === 'Break'
? '连续10分钟异常停止采集数据进入熔断状态设备重新启用后恢复采集状态'
: '忽略异常保持原采集频率超时时间为5s';
const handleOk = () => {
onSubmit();
};
const handleCancel = () => {
emit('change', false);
};
// const getChannelNoPaging = async () => {
// channelListAll.value = Store.get('channelListAll');
// channelList.value = channelListAll.value.map((item) => ({
// value: item.id,
// label: item.name,
// }));
// };
// getChannelNoPaging();
const getProviderList = async () => {
const res = await queryCodecProvider();
console.log(222, res.result);
providerList.value = res.result
.filter((i) => i.id !== 'property')
.map((item) => ({
value: item.id,
label: item.name,
}));
};
getProviderList();
// watch(
// () => formData.value.channelId,
// (value) => {
// const dt = channelListAll.value.find((item) => item.id === value);
// visibleUnitId.value = visibleEndian.value =
// dt?.provider && dt?.provider === 'MODBUS_TCP';
// },
// { deep: true },
// );
watch(
() => props.data,
(value) => {
if (value.id) formData.value = value;
},
{ immediate: true, deep: true },
);
</script>
<style lang="less" scoped>
.form {
.form-radio-button {
width: 148px;
height: 80px;
padding: 0;
img {
width: 100%;
height: 100%;
}
}
.form-upload-button {
margin-top: 10px;
}
.form-submit {
background-color: @primary-color !important;
}
}
</style>

View File

@ -0,0 +1,415 @@
<template>
<div class="card">
<div
class="card-warp"
:class="{ active: active ? 'active' : '' }"
@click="handleClick"
>
<div class="card-content">
<div class="card-header">
<div class="card-header-left">
<slot name="title"> </slot>
</div>
<div class="card-header-right">
<slot name="action"> </slot>
</div>
</div>
<div style="display: flex">
<!-- 图片 -->
<div class="card-item-avatar">
<slot name="img"> </slot>
</div>
<!-- 内容 -->
<div class="card-item-body">
<slot name="content"></slot>
</div>
</div>
<!-- 勾选 -->
<div v-if="active" class="checked-icon">
<div>
<AIcon type="CheckOutlined" />
</div>
</div>
<!-- 状态 -->
<div
v-if="showStatus"
class="card-state"
:class="statusNames ? statusNames[status] : ''"
>
<div class="card-state-content">
<BadgeStatus
:status="status"
:text="statusText"
:statusNames="statusNames"
></BadgeStatus>
</div>
</div>
</div>
<div class="card-mask" v-if="props.hasMark">
<div class="mask-content">
<slot name="mark" />
</div>
</div>
</div>
<!-- 按钮 -->
<slot name="bottom-tool">
<div
v-if="showTool && actions && actions.length"
class="card-tools"
>
<div
v-for="item in actions"
:key="item.key"
class="card-button"
:class="{
delete: item.key === 'delete',
}"
>
<slot name="actions" v-bind="item"></slot>
</div>
</div>
</slot>
</div>
</template>
<script setup lang="ts">
import BadgeStatus from '@/components/BadgeStatus/index.vue';
import type { ActionsType } from '@/components/Table/index.vue';
import { PropType } from 'vue';
type EmitProps = {
// (e: 'update:modelValue', data: Record<string, any>): void;
(e: 'click', data: Record<string, any>): void;
};
type TableActionsType = Partial<ActionsType>;
const emit = defineEmits<EmitProps>();
const props = defineProps({
value: {
type: Object as PropType<Record<string, any>>,
default: () => {},
},
showStatus: {
type: Boolean,
default: true,
},
showTool: {
type: Boolean,
default: true,
},
statusText: {
type: String,
default: '正常',
},
status: {
type: [String, Number],
default: 'default',
},
statusNames: {
type: Object,
},
actions: {
type: Array as PropType<TableActionsType[]>,
default: () => [],
},
active: {
type: Boolean,
default: false,
},
hasMark: {
type: Boolean,
default: false,
},
});
const handleClick = () => {
emit('click', props.value);
};
</script>
<style lang="less" scoped>
.card {
width: 100%;
background-color: #fff;
.checked-icon {
position: absolute;
right: -22px;
bottom: -22px;
z-index: 2;
width: 44px;
height: 44px;
color: #fff;
background-color: red;
background-color: #2f54eb;
transform: rotate(-45deg);
> div {
position: relative;
height: 100%;
transform: rotate(45deg);
> span {
position: absolute;
top: 6px;
left: 6px;
font-size: 12px;
}
}
}
.card-warp {
position: relative;
border: 1px solid #e6e6e6;
&:hover {
cursor: pointer;
box-shadow: 0 0 24px rgba(#000, 0.1);
.card-mask {
visibility: visible;
}
}
&.active {
position: relative;
border: 1px solid #2f54eb;
}
.card-content {
position: relative;
padding: 40px 12px 16px 30px;
overflow: hidden;
&::before {
position: absolute;
top: 0;
left: 30px + 10px;
display: block;
width: 15%;
min-width: 64px;
height: 2px;
// background-image: url('/images/rectangle.png');
background-repeat: no-repeat;
background-size: 100% 100%;
content: ' ';
}
.card-header {
// display: flex;
position: absolute;
left: 133px;
top: 20px;
// background: #5995f5;
// width: calc(100% - 100px);
width: calc(100% - 150px);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-item-avatar {
margin-right: 16px;
}
.card-item-body {
display: flex;
flex-direction: column;
flex-grow: 1;
width: 0;
}
.card-state {
position: absolute;
top: 0px;
left: -12px;
display: flex;
justify-content: center;
width: 100px;
padding: 2px 0;
background-color: rgba(#5995f5, 0.15);
transform: skewX(-45deg);
&.success {
background-color: @success-color-deprecated-bg;
}
&.warning {
background-color: rgba(#ff9000, 0.1);
}
&.error {
background-color: rgba(#e50012, 0.1);
}
.card-state-content {
transform: skewX(45deg);
}
}
:deep(.card-item-content-title) {
cursor: pointer;
font-size: 16px;
font-weight: 700;
color: @primary-color;
width: calc(100% - 100px);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:deep(.card-item-heard-name) {
font-weight: 700;
font-size: 16px;
margin-bottom: 12px;
}
:deep(.card-item-content-text) {
color: rgba(0, 0, 0, 0.75);
font-size: 12px;
}
}
.card-mask {
position: absolute;
top: 0;
left: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #fff;
background-color: rgba(#000, 0.5);
visibility: hidden;
cursor: pointer;
transition: all 0.3s;
.mask-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 0 !important;
}
}
}
&.item-active {
position: relative;
color: #2f54eb;
.checked-icon {
display: block;
}
.card-warp {
border: 1px solid #2f54eb;
}
}
.card-tools {
display: flex;
margin-top: 8px;
.card-button {
display: flex;
flex-grow: 1;
& > :deep(span, button) {
width: 100%;
border-radius: 0;
}
:deep(button) {
width: 100%;
border-radius: 0;
background: #f6f6f6;
border: 1px solid #e6e6e6;
color: #2f54eb;
&:hover {
background-color: @primary-color-hover;
border-color: @primary-color-hover;
span {
color: #fff !important;
}
}
&:active {
background-color: @primary-color-active;
border-color: @primary-color-active;
span {
color: #fff !important;
}
}
}
&:not(:last-child) {
margin-right: 8px;
}
&.delete {
flex-basis: 60px;
flex-grow: 0;
:deep(button) {
background: @error-color-deprecated-bg;
border: 1px solid @error-color-outline;
span {
color: @error-color !important;
}
&:hover {
background-color: @error-color-hover;
span {
color: #fff !important;
}
}
&:active {
background-color: @error-color-active;
span {
color: #fff !important;
}
}
}
}
:deep(button[disabled]) {
background: @disabled-bg;
border-color: @disabled-color;
span {
color: @disabled-color !important;
}
&:hover {
background-color: @disabled-active-bg;
}
&:active {
background-color: @disabled-active-bg;
}
}
// :deep(.ant-tooltip-disabled-compatible-wrapper) {
// width: 100%;
// }
}
}
}
</style>

View File

@ -0,0 +1,484 @@
<template>
<div>
<j-advanced-search
:columns="columns"
target="search"
@search="handleSearch"
/>
<j-pro-table
ref="tableRef"
model="CARD"
:columns="columns"
:gridColumn="2"
:gridColumns="[1, 2]"
:request="queryPoint"
:defaultParams="{
sorts: [{ name: 'id', order: 'desc' }],
}"
:params="params"
:rowSelection="{
selectedRowKeys: _selectedRowKeys,
onChange: onSelectChange,
}"
@cancelSelect="cancelSelect"
>
<template #headerTitle>
<j-space>
<PermissionButton
type="primary"
@click="handlAdd"
hasPermission="DataCollect/Collector:add"
>
<template #icon><AIcon type="PlusOutlined" /></template>
{{ data?.provider === 'OPC_UA' ? '扫描' : '新增点位' }}
</PermissionButton>
<j-dropdown v-if="data?.provider === 'OPC_UA'">
<j-button
>批量操作 <AIcon type="DownOutlined"
/></j-button>
<template #overlay>
<j-menu>
<j-menu-item>
<PermissionButton
hasPermission="DataCollect/Collector:update"
>
<template #icon
><AIcon type="FormOutlined"
/></template>
编辑
</PermissionButton>
</j-menu-item>
<j-menu-item>
<PermissionButton
hasPermission="DataCollect/Collector:delete"
>
<template #icon
><AIcon type="EditOutlined"
/></template>
删除
</PermissionButton>
</j-menu-item>
</j-menu>
</template>
</j-dropdown>
</j-space>
</template>
<template #card="slotProps">
<PointCardBox
:showStatus="true"
:value="slotProps"
@click="handleClick"
:active="_selectedRowKeys.includes(slotProps.id)"
class="card-box"
:status="getState(slotProps).value"
:statusText="getState(slotProps)?.text"
:statusNames="Object.fromEntries(colorMap.entries())"
>
<!-- <PointCardBox
:showStatus="true"
:value="slotProps"
:actions="getActions(slotProps)"
:active="_selectedRowKeys.includes(slotProps.id)"
v-bind="slotProps"
class="card-box"
:status="getState(slotProps).value"
:statusText="slotProps.runningState?.text"
:statusNames="Object.fromEntries(colorMap.entries())"
> -->
<template #title>
<slot name="title">
<div class="card-box-title">
{{ slotProps.name }}
</div>
</slot>
</template>
<template #action>
<div class="card-box-action">
<a><AIcon type="DeleteOutlined" /></a>
<a><AIcon type="FormOutlined" /></a>
</div>
</template>
<template #img>
<img
:src="
slotProps.provider === 'OPC_UA'
? opcImage
: modbusImage
"
/>
</template>
<template #content>
<div class="card-box-content">
<div class="card-box-content-left">
<span>--</span>
<a><AIcon type="EditOutlined" /></a>
<a><AIcon type="RedoOutlined" /></a>
</div>
<div class="card-box-content-right">
<div
v-if="getRight1(slotProps)"
class="card-box-content-right-1"
>
<span>{{ getQuantity(slotProps) }}</span>
<span>{{ getAddress(slotProps) }}</span>
<span>{{ getScaleFactor(slotProps) }}</span>
</div>
<div class="card-box-content-right-2">
<span>{{ getText(slotProps) }}</span>
<span>{{ getInterval(slotProps) }}</span>
</div>
</div>
</div>
</template>
</PointCardBox>
</template>
</j-pro-table>
<SaveModBus
v-if="visibleSaveModBus"
:data="current"
@change="saveChange"
/>
</div>
</template>
<script lang="ts" setup name="PointPage">
import type { ActionsType } from '@/components/Table/index.vue';
import { getImage } from '@/utils/comm';
import { queryPoint } from '@/api/data-collect/collector';
import { message } from 'ant-design-vue';
import { useMenuStore } from 'store/menu';
import PointCardBox from './components/PointCardBox/index.vue';
import { colorMap, getState } from '../data.ts';
import SaveModBus from './Save/SaveModBus.vue';
const props = defineProps({
data: {
type: Object,
default: {},
},
});
const menuStory = useMenuStore();
const tableRef = ref<Record<string, any>>({});
const params = ref<Record<string, any>>({});
const opcImage = getImage('/DataCollect/device-opcua.png');
const modbusImage = getImage('/DataCollect/device-modbus.png');
const visibleSaveModBus = ref(false);
const current = ref({});
const accessModesOption = ref();
const _selectedRowKeys = ref<string[]>([]);
const accessModesMODBUS_TCP = [
{
label: '读',
value: 'read',
},
{
label: '写',
value: 'write',
},
];
const accessModesOPC_UA = accessModesMODBUS_TCP.concat({
label: '订阅',
value: 'subscribe',
});
const columns = [
{
title: '点位名称',
dataIndex: 'name',
key: 'name',
search: {
type: 'string',
},
},
{
title: '通讯协议',
dataIndex: 'provider',
key: 'provider',
search: {
type: 'select',
options: [
{
label: 'OPC_UA',
value: 'OPC_UA',
},
{
label: 'MODBUS_TCP',
value: 'MODBUS_TCP',
},
],
},
},
{
title: '访问类型',
dataIndex: 'accessModes$in$any',
key: 'accessModes$in$any',
search: {
type: 'select',
options: accessModesOption,
},
},
{
title: '运行状态',
dataIndex: 'runningState',
key: 'runningState',
search: {
type: 'select',
options: [
{
label: '运行中',
value: 'running',
},
{
label: '部分错误',
value: 'partialError',
},
{
label: '错误',
value: 'failed',
},
{
label: '已停止',
value: 'stopped',
},
],
},
},
{
title: '说明',
dataIndex: 'description',
key: 'description',
search: {
type: 'string',
},
},
];
// const getActions = (data: Partial<Record<string, any>>): ActionsType[] => {
// if (!data) return [];
// const state = data.state.value;
// const stateText = state === 'enabled' ? '' : '';
// const actions = [
// {
// key: 'update',
// text: '',
// tooltip: {
// title: '',
// },
// icon: 'EditOutlined',
// onClick: () => {
// handlEdit(data.id);
// },
// },
// {
// key: 'action',
// text: stateText,
// tooltip: {
// title: stateText,
// },
// icon: state === 'enabled' ? 'StopOutlined' : 'CheckCircleOutlined',
// popConfirm: {
// title: `${stateText}?`,
// onConfirm: async () => {
// let res =
// state === 'enabled'
// ? await disable(data.id)
// : await enalbe(data.id);
// if (res.success) {
// message.success('');
// tableRef.value?.reload();
// } else {
// message.error('');
// }
// },
// },
// },
// {
// key: 'delete',
// text: '',
// disabled: state === 'enabled',
// tooltip: {
// title: state === 'enabled' ? '' : '',
// },
// popConfirm: {
// title: '?',
// onConfirm: async () => {
// const res = await remove(data.id);
// if (res.success) {
// message.success('');
// tableRef.value.reload();
// } else {
// message.error('');
// }
// },
// },
// icon: 'DeleteOutlined',
// },
// ];
// return actions;
// };
const handlAdd = () => {
visibleSaveModBus.value = true;
current.value = {
treeId: props.data.id,
};
};
const handlEdit = (id: string) => {
// menuStory.jumpPage(`media/Stream/Detail`, { id }, { view: false });
};
const handlEye = (id: string) => {
// menuStory.jumpPage(`media/Stream/Detail`, { id }, { view: true });
};
const getQuantity = (item: Partial<Record<string, any>>) => {
const { quantity } = item.configuration?.parameter || '';
return !!quantity ? quantity + '(读取寄存器)' : '';
};
const getAddress = (item: Partial<Record<string, any>>) => {
const { address } = item.configuration?.parameter || '';
return !!address ? address + '(地址)' : '';
};
const getScaleFactor = (item: Partial<Record<string, any>>) => {
const { scaleFactor } = item.configuration?.codec?.configuration || '';
return !!scaleFactor ? scaleFactor + '(缩放因子)' : '';
};
const getRight1 = (item: Partial<Record<string, any>>) => {
return !!getQuantity(item) || getAddress(item) || getScaleFactor(item);
};
const getText = (item: Partial<Record<string, any>>) => {
return (item?.accessModes || []).map((i) => i?.text).join(',');
};
const getInterval = (item: Partial<Record<string, any>>) => {
const { interval } = item.configuration || '';
return !!interval ? '采集频率' + interval + 'ms' : '';
};
const getaccessModesOption = () => {
return props.data?.provider !== 'MODBUS_TCP'
? accessModesMODBUS_TCP.concat({
label: '订阅',
value: 'subscribe',
})
: accessModesMODBUS_TCP;
};
const saveChange = (value: object) => {
visibleSaveModBus.value = false;
current.value = {};
if (value) {
handleSearch(params.value);
// message.success('');
}
};
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];
}
};
watch(
() => props.data,
(value) => {
if (!!value) {
accessModesOption.value =
value.provider === 'MODBUS_TCP'
? accessModesMODBUS_TCP
: accessModesMODBUS_TCP.concat({
label: '订阅',
value: 'subscribe',
});
params.value = {
terms: [
{
terms: [
{
column: 'collectorId',
value: value.id,
},
],
},
],
};
}
},
{ deep: true, immediate: true },
);
/**
* 搜索
* @param params
*/
const handleSearch = (e: any) => {
params.value = e;
};
</script>
<style lang="less" scoped>
.card-box {
min-width: 480px;
a {
color: #474747;
}
a:hover {
color: #315efb;
}
.card-box-title {
font-size: 18px;
color: #474747;
}
.card-box-action {
width: 50px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 20px;
}
.card-box-content {
margin-top: 10px;
display: flex;
.card-box-content-left {
flex: 0.2;
border-right: 1px solid #e0e4e8;
height: 68px;
padding-right: 20px;
display: flex;
justify-content: space-between;
}
.card-box-content-right {
flex: 0.8;
margin-left: 20px;
.card-box-content-right-1 {
span {
margin: 0 5px 0 0;
}
margin-bottom: 10px;
}
.card-box-content-right-2 {
span {
margin: 0 5px 0 0;
padding: 3px 12px;
background: #f3f3f3;
color: #616161;
}
}
}
}
}
</style>

View File

@ -0,0 +1,278 @@
<template lang="">
<j-modal
:title="data.id ? '编辑' : '新增'"
:visible="true"
width="700px"
@cancel="handleCancel"
>
<j-form
class="form"
layout="vertical"
:model="formData"
name="basic"
autocomplete="off"
>
<j-form-item label="所属通道" v-bind="validateInfos.channelId">
<j-select
style="width: 100%"
v-model:value="formData.channelId"
:options="channelList"
placeholder="请选择所属通道"
allowClear
show-search
:filter-option="filterOption"
:disabled="!!id"
/>
</j-form-item>
<j-form-item label="采集器名称" v-bind="validateInfos.name">
<j-input
placeholder="请输入采集器名称"
v-model:value="formData.name"
/>
</j-form-item>
<j-form-item
label="从机地址"
v-bind="validateInfos['configuration.unitId']"
v-if="visibleUnitId"
>
<j-input-number
style="width: 100%"
placeholder="请输入从机地址"
v-model:value="formData.configuration.unitId"
:min="0"
:max="255"
/>
</j-form-item>
<j-form-item v-bind="validateInfos['circuitBreaker.type']">
<template #label>
<span>
故障处理
<j-tooltip
:title="
getTypeTooltip(formData.circuitBreaker.type)
"
>
<AIcon
type="QuestionCircleOutlined"
style="margin-left: 2px"
/>
</j-tooltip>
</span>
</template>
<RadioCard
layout="horizontal"
:checkStyle="true"
:options="[
{ label: '降频', value: 'LowerFrequency' },
{ label: '熔断', value: 'Break' },
{ label: '忽略', value: 'Ignore' },
]"
v-model="formData.circuitBreaker.type"
/>
</j-form-item>
<j-form-item
v-bind="validateInfos['configuration.endian']"
v-if="visibleEndian"
>
<template #label>
<span>
高低位切换
<j-tooltip title="统一配置所有点位的高低位切换">
<AIcon
type="QuestionCircleOutlined"
style="margin-left: 2px"
/>
</j-tooltip>
</span>
</template>
<RadioCard
layout="horizontal"
:checkStyle="true"
:options="[
{ label: 'AB', value: 'BIG' },
{ label: 'BA', value: 'LITTLE' },
]"
v-model="formData.configuration.endian"
/>
</j-form-item>
<j-form-item label="说明" v-bind="validateInfos.description">
<j-textarea
placeholder="请输入说明"
v-model:value="formData.description"
:maxlength="200"
:rows="3"
showCount
/>
</j-form-item>
</j-form>
<template #footer>
<j-button key="back" @click="handleCancel">取消</j-button>
<PermissionButton
key="submit"
type="primary"
:loading="loading"
@click="handleOk"
style="margin-left: 8px"
:hasPermission="`DataCollect/Collector:${
id ? 'update' : 'add'
}`"
>
确认
</PermissionButton>
</template>
</j-modal>
</template>
<script lang="ts" setup>
import { Form } from 'ant-design-vue';
import { save, update } from '@/api/data-collect/collector';
import { Store } from 'jetlinks-store';
const loading = ref(false);
const useForm = Form.useForm;
const channelListAll = ref();
const channelList = ref();
const visibleEndian = ref(false);
const visibleUnitId = ref(false);
const props = defineProps({
data: {
type: Object,
default: () => {},
},
});
const emit = defineEmits(['change']);
const id = props.data.id;
const formData = ref({
channelId: undefined,
name: '',
configuration: {
unitId: '',
type: 'LowerFrequency',
endian: 'BIG',
},
circuitBreaker: {
type: 'LowerFrequency',
},
description: '',
});
const regOnlyNumber = new RegExp(/^\d+$/);
const { resetFields, validate, validateInfos } = useForm(
formData,
reactive({
channelId: [
{ required: true, message: '请选择所属通道', trigger: 'blur' },
],
name: [
{ required: true, message: '请输入采集器名称', trigger: 'blur' },
{ max: 64, message: '最多可输入64个字符' },
],
'configuration.unitId': [
{ required: true, message: '请输入从机地址', trigger: 'blur' },
{
pattern: regOnlyNumber,
message: '请输入0-255之间的正整数',
},
],
'circuitBreaker.type': [
{ required: true, message: '请选择处理方式', trigger: 'blur' },
],
'configuration.endian': [
{ required: true, message: '请选择高低位切换', trigger: 'blur' },
],
description: [{ max: 200, message: '最多可输入200个字符' }],
}),
);
const onSubmit = () => {
validate()
.then(async (res) => {
const { provider, name } = channelListAll.value.find(
(item) => item.id === formData.value.channelId,
);
const params = {
...toRaw(formData.value),
provider,
channelName: name,
};
loading.value = true;
const response = !id
? await save(params)
: await update(id, { ...props.data, ...params });
if (response.status === 200) {
emit('change', true);
}
loading.value = false;
})
.catch((err) => {
loading.value = false;
});
};
const getTypeTooltip = (value: string) =>
value === 'LowerFrequency'
? '连续20次异常降低连接频率至原有频率的1/10重试间隔不超过1分钟故障处理后自动恢复至设定连接频率'
: value === 'Break'
? '连续10分钟异常停止采集数据进入熔断状态设备重新启用后恢复采集状态'
: '忽略异常保持原采集频率超时时间为5s';
const handleOk = () => {
onSubmit();
};
const handleCancel = () => {
emit('change', false);
};
const getChannelNoPaging = async () => {
channelListAll.value = Store.get('channelListAll');
channelList.value = channelListAll.value.map((item) => ({
value: item.id,
label: item.name,
}));
};
getChannelNoPaging();
watch(
() => formData.value.channelId,
(value) => {
const dt = channelListAll.value.find((item) => item.id === value);
visibleUnitId.value = visibleEndian.value =
dt?.provider && dt?.provider === 'MODBUS_TCP';
},
{ deep: true },
);
watch(
() => props.data,
(value) => {
if (value.id) formData.value = value;
},
{ immediate: true, deep: true },
);
</script>
<style lang="less" scoped>
.form {
.form-radio-button {
width: 148px;
height: 80px;
padding: 0;
img {
width: 100%;
height: 100%;
}
}
.form-upload-button {
margin-top: 10px;
}
.form-submit {
background-color: @primary-color !important;
}
}
</style>

View File

@ -10,10 +10,12 @@
<div class="add-btn">
<PermissionButton
type="primary"
class="add-btn"
@click="openDialog()"
type="primary"
@click="handlAdd()"
hasPermission="DataCollect/Collector:add"
>
<template #icon><AIcon type="PlusOutlined" /></template>
新增采集器
</PermissionButton>
</div>
@ -21,18 +23,10 @@
<a-tree
:tree-data="defualtDataSource"
v-model:selected-keys="selectedKeys"
:fieldNames="{ key: 'name' }"
:fieldNames="{ key: 'id' }"
v-if="defualtDataSource[0].children.length !== 0"
@check="checkTree"
>
<!-- <a-tree
:tree-data="defualtDataSource"
v-model:selected-keys="selectedKeys"
:fieldNames="{ key: 'name' }"
:height="600"
v-if="defualtDataSource[0].children.length !== 0"
@check="checkTree"
> -->
>
<template #title="{ name, data }">
<Ellipsis class="tree-left-title">
{{ name }}
@ -53,42 +47,43 @@
:tooltip="{
title: '编辑',
}"
@click="openDialog(data)"
@click="handlEdit(data)"
hasPermission="DataCollect/Collector:update"
>
<AIcon type="EditOutlined" />
</PermissionButton>
<PermissionButton
v-if="data?.state?.value === 'disabled'"
type="link"
:tooltip="{
title: '启用',
title:
data?.state?.value === 'disabled'
? '启用'
: '禁用',
}"
@click="openDialog(data)"
hasPermission="DataCollect/Collector:update"
@click="handlUpdate(data)"
>
<AIcon type="CheckCircleOutlined" />
</PermissionButton>
<PermissionButton
v-if="data?.state?.value !== 'disabled'"
type="link"
:tooltip="{
title: '禁用',
}"
@click="
openDialog({
...data,
id: '',
parentId: data.id,
})
"
>
<AIcon type="StopOutlined" />
<AIcon
:type="
data?.state?.value === 'disabled'
? 'CheckCircleOutlined'
: 'StopOutlined'
"
/>
</PermissionButton>
<PermissionButton
type="link"
:tooltip="{ title: '删除' }"
:disabled="data?.state?.value !== 'disabled'"
:tooltip="{
title:
data?.state?.value !== 'disabled'
? '正常的采集器不能删除'
: '删除',
}"
hasPermission="DataCollect/Collector:delete"
:popConfirm="{
title: `确定要删除吗`,
onConfirm: () => openDialog(data.id),
title: `该操作将会删除下属点位,确定删除?`,
onConfirm: () => handlDelete(data.id),
}"
>
<AIcon type="DeleteOutlined" />
@ -98,13 +93,22 @@
</a-tree>
<j-empty v-else description="暂无数据" />
</a-spin>
<Save v-if="visible" :data="current" @change="saveChange" />
</div>
</template>
<script setup lang="ts" name="TreePage">
import type { TreeProps } from 'ant-design-vue';
import { treeFilter } from '@/utils/comm';
import { queryCollector } from '@/api/data-collect/collector';
import {
queryCollector,
queryChannelNoPaging,
update,
remove,
} from '@/api/data-collect/collector';
import Save from './Save/index.vue';
import { message } from 'ant-design-vue';
import { Store } from 'jetlinks-store';
import _ from 'lodash';
import { colorMap, getState } from '../data.ts';
const props = defineProps({
data: {
@ -116,16 +120,12 @@ const emits = defineEmits(['change']);
const route = useRoute();
const channelId = route.query?.channelId;
const colorMap = new Map();
colorMap.set('running', 'success');
colorMap.set('partialError', 'warning');
colorMap.set('failed', 'error');
colorMap.set('stopped', 'default');
const spinning = ref(false);
const selectedKeys = ref();
const selectedKeys = ref([]);
const searchValue = ref();
const visible = ref(false);
const current = ref({});
const collectorAll = ref();
const defualtDataSource = ref([
{
@ -154,17 +154,49 @@ const defualtParams = {
};
const params = ref();
const openDialog = (row: any = {}) => {
console.log(row);
const handlAdd = () => {
current.value = {};
visible.value = true;
};
const checkTree = (value: any) => {
console.log(22, value);
const handlEdit = (data: object) => {
current.value = _.cloneDeep(data);
visible.value = true;
};
const handlUpdate = async (data: object) => {
const state = data?.state?.value;
const resp = await update(data?.id, {
state: state !== 'disabled' ? 'disabled' : 'enabled',
runningState: state !== 'disabled' ? 'stopped' : 'running',
});
if (resp.status === 200) {
handleSearch(params.value);
message.success('操作成功');
}
};
const handlDelete = async (id: string) => {
const resp = await remove(id);
if (resp.status === 200) {
handleSearch(params.value);
message.success('操作成功');
}
};
const saveChange = (value: object) => {
visible.value = false;
current.value = {};
if (value) {
handleSearch(params.value);
message.success('操作成功');
}
};
const handleSearch = async (value: string) => {
if (!!searchValue.value) {
params.value = { ...defualtParams };
if (!searchValue.value && !value) {
params.value = _.cloneDeep(defualtParams);
} else if (!!searchValue.value) {
params.value = { ..._.cloneDeep(defualtParams) };
params.value.terms[1] = {
terms: [
{
@ -181,55 +213,37 @@ const handleSearch = async (value: string) => {
const res = await queryCollector(params.value);
if (res.status === 200) {
defualtDataSource.value[0].children = res.result;
collectorAll.value = res.result;
if (selectedKeys.value.length === 0) {
selectedKeys.value = [res?.result[0]?.id] || [];
emits('change', res?.result[0]);
}
}
spinning.value = false;
};
const getState = (record: any) => {
const enabled = record?.state?.value === 'enabled';
if (record) {
return enabled
? {
value: record?.runningState?.value,
text: record?.runningState?.text,
}
: {
value: 'processing',
text: '禁用',
};
} else {
return {};
}
const getChannelNoPaging = async () => {
const res = await queryChannelNoPaging();
Store.set('channelListAll', res.result);
};
onMounted(() => {
handleSearch(defualtParams);
handleSearch(_.cloneDeep(defualtParams));
getChannelNoPaging();
});
watch(selectedKeys, (n) => {
emits('change', n[0]);
const key = _.isArray(n) ? n[0] : n;
const row = collectorAll.value.find((i) => i.id === key);
emits('change', row);
});
// watch(
// () => route.query,
// (value) => {
// if (value?.channelId) {
// params.value = {
// ...defualtParams,
// terms: [
// {
// column: 'channelId',
// value: value?.channelId,
// },
// ],
// };
// handleSearch(params.value);
// } else {
// handleSearch(defualtParams);
// }
// },
// { immediate: true, deep: true },
// );
watch(
() => searchValue.value,
(value) => {
!value && handleSearch(value);
},
);
</script>
<style lang="less" scoped>
@ -253,16 +267,13 @@ watch(selectedKeys, (n) => {
display: flex;
justify-content: space-between;
align-items: center;
height: 20px;
.tree-left-title {
width: 80px;
// margin-left: -5px;
}
.tree-left-tag {
width: 70px;
display: flex;
justify-content: center;
align-items: center;
}
.func-btns {
// display: none;

View File

@ -0,0 +1,18 @@
export const colorMap = new Map();
colorMap.set('running', 'success');
colorMap.set('partialError', 'warning');
colorMap.set('failed', 'error');
colorMap.set('stopped', 'default');
colorMap.set('processing', '#cccccc');
export const getState = (record: any) => {
const enabled = record?.state?.value === 'enabled';
if (record) {
return {
value: enabled ? record?.runningState?.value : 'processing',
text: enabled ? record?.runningState?.text : '禁用',
};
} else {
return {};
}
};

View File

@ -4,16 +4,21 @@
<div class="left">
<Tree @change="changeTree" />
</div>
<div class="right">right</div>
<div class="right">
<Point :data="data"></Point>
</div>
</div>
</page-container>
</template>
<script setup lang="ts" name="CollectorPage">
import Tree from './Tree/index.vue';
import Point from './Point/index.vue';
const changeTree = (value: any) => {
console.log(32, value);
const data = ref();
const changeTree = (row: any) => {
data.value = row;
};
</script>
@ -31,6 +36,7 @@ const changeTree = (value: any) => {
margin: 10px;
}
.right {
flex: 1;
padding: 10px;
}
}

View File

@ -60,7 +60,6 @@ import { useMenuStore } from 'store/menu';
const menuStory = useMenuStore();
const tableRef = ref<Record<string, any>>({});
const router = useRouter();
const route = useRoute();
const params = ref<Record<string, any>>({});