You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1314 lines
45 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="workbench-page p-2">
<el-card shadow="never" class="hero-card">
<div class="hero-grid">
<div class="hero-main">
<div class="hero-kicker">INTEGRATION WORKBENCH</div>
<div class="hero-title">协议联调工作台</div>
<div class="hero-desc">将端点配置点位映射报文预览命令下发和日志留痕集中到一个工作区避免在多个后台台账页之间来回切换</div>
<div class="hero-tags">
<el-tag effect="dark" round class="hero-tag">{{ selectedEndpointName }}</el-tag>
<el-tag effect="plain" round class="hero-tag">{{ selectedEndpointTypeLabel }}</el-tag>
<el-tag effect="plain" round class="hero-tag">{{ selectedEndpointStatusLabel }}</el-tag>
</div>
</div>
<div class="hero-side">
<div class="hero-panel">
<div class="hero-panel-label">当前工作端点</div>
<div class="hero-panel-value">{{ selectedEndpointName }}</div>
<div class="hero-panel-foot">支持连接测试报文预览和命令模拟下发后续真实协议适配可继续从这里向下扩展</div>
</div>
</div>
</div>
</el-card>
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
<div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover" class="query-card">
<el-form ref="queryFormRef" :model="queryParams" :inline="true" label-width="88px">
<el-form-item label="端点名称" prop="endpointName">
<el-input v-model="queryParams.endpointName" placeholder="请输入端点名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="端点类型" prop="endpointType">
<el-select v-model="queryParams.endpointType" placeholder="请选择端点类型" clearable style="width: 180px">
<el-option v-for="item in endpointTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="访问模式" prop="accessMode">
<el-select v-model="queryParams.accessMode" placeholder="请选择访问模式" clearable style="width: 180px">
<el-option v-for="item in accessModeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="主机地址" prop="host">
<el-input v-model="queryParams.host" placeholder="请输入主机地址" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
<el-option v-for="item in endpointStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
<el-button type="success" plain icon="Plus" @click="handleAdd" v-hasPermi="['ems/base:integrationEndpoint:add']">新增端点</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</transition>
<div class="summary-grid">
<el-card shadow="never" class="summary-card">
<div class="summary-label">端点总数</div>
<div class="summary-value">{{ integrationEndpointList.length }}</div>
<div class="summary-foot">当前筛选条件下可参与联调的端点数</div>
</el-card>
<el-card shadow="never" class="summary-card">
<div class="summary-label">映射条数</div>
<div class="summary-value">{{ pointMapList.length }}</div>
<div class="summary-foot">当前端点下已配置的点位映射</div>
</el-card>
<el-card shadow="never" class="summary-card">
<div class="summary-label">最近命令日志</div>
<div class="summary-value">{{ commandLogList.length }}</div>
<div class="summary-foot">默认展示最近 20 条联调命令记录</div>
</el-card>
<el-card shadow="never" class="summary-card">
<div class="summary-label">当前目标点位</div>
<div class="summary-value summary-text">{{ selectedMonitorName }}</div>
<div class="summary-foot">命令预览和下发会优先使用当前选中的映射</div>
</el-card>
</div>
<el-row :gutter="12">
<el-col :xs="24" :lg="9">
<el-card shadow="never" class="endpoint-card">
<template #header>
<div class="card-header">
<div>
<div class="card-title">端点清单</div>
<div class="card-subtitle">先选端点,再做连接测试、点位预览和命令下发</div>
</div>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
</div>
</template>
<el-table
ref="endpointTableRef"
v-loading="loading"
:data="integrationEndpointList"
row-key="id"
highlight-current-row
@row-click="handleEndpointRowClick"
>
<el-table-column label="端点名称" min-width="190" show-overflow-tooltip>
<template #default="scope">
<div class="main-cell">{{ scope.row.endpointName || '-' }}</div>
<div class="sub-cell">{{ scope.row.endpointType || '-' }} / {{ scope.row.accessMode || '-' }}</div>
</template>
</el-table-column>
<el-table-column label="地址" min-width="170" show-overflow-tooltip>
<template #default="scope"> {{ scope.row.host || '-' }}:{{ scope.row.port || '-' }} </template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="scope">
<el-tag :type="getEndpointStatusMeta(scope.row.status).type" effect="light">
{{ getEndpointStatusMeta(scope.row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center">
<template #default="scope">
<el-button link type="primary" icon="Edit" @click.stop="handleUpdate(scope.row)" v-hasPermi="['ems/base:integrationEndpoint:edit']" />
<el-button
link
type="danger"
icon="Delete"
@click.stop="handleDelete(scope.row)"
v-hasPermi="['ems/base:integrationEndpoint:remove']"
/>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :lg="15">
<el-card shadow="never" class="workbench-card" v-loading="workbenchLoading">
<template #header>
<div class="card-header">
<div>
<div class="card-title">联调主面板</div>
<div class="card-subtitle">端点、映射、报文、日志四块在同一屏联动</div>
</div>
<div class="header-actions">
<el-button type="primary" plain :disabled="!selectedEndpoint" :loading="testLoading" @click="handleTestConnection"
>测试连接</el-button
>
<el-button type="warning" plain :disabled="!selectedEndpoint" :loading="previewLoading" @click="handlePreviewPayload"
>双栏预览</el-button
>
</div>
</div>
</template>
<el-empty v-if="!selectedEndpoint" description="请选择左侧端点后开始联调" />
<template v-else>
<el-descriptions :column="2" border class="mb-3" size="small">
<el-descriptions-item label="端点名称">{{ selectedEndpoint.endpointName || '-' }}</el-descriptions-item>
<el-descriptions-item label="端点类型">{{ selectedEndpoint.endpointType || '-' }}</el-descriptions-item>
<el-descriptions-item label="访问模式">{{ selectedEndpoint.accessMode || '-' }}</el-descriptions-item>
<el-descriptions-item label="主机地址">{{ selectedEndpoint.host || '-' }}:{{ selectedEndpoint.port || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getEndpointStatusMeta(selectedEndpoint.status).type" effect="light">
{{ getEndpointStatusMeta(selectedEndpoint.status).label }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="扩展配置">
<span class="json-summary">{{ selectedEndpoint.configJson ? '已配置 JSON 扩展参数' : '未配置 JSON 扩展参数' }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="command-panel">
<div class="section-title">命令调试</div>
<el-form :model="commandForm" label-width="94px" class="command-form">
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<el-form-item label="映射对象">
<el-select
v-model="commandForm.mapId"
placeholder="请选择映射对象"
filterable
clearable
style="width: 100%"
@change="handleMapChange"
>
<el-option
v-for="item in pointMapList"
:key="item.id"
:label="`${item.monitorName || item.monitorCode} / ${item.metricCode}`"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="数据格式">
<el-select v-model="commandForm.dataFormat" style="width: 100%">
<el-option label="JSON" value="JSON" />
<el-option label="XML" value="XML" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<el-form-item label="命令类型">
<el-select v-model="commandForm.commandType" style="width: 100%">
<el-option label="写入指令" value="WRITE" />
<el-option label="读取指令" value="READ" />
<el-option label="心跳校验" value="PING" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="命令值">
<el-input v-model="commandForm.commandValue" placeholder="请输入命令值,如 1 / 0 / RESET" />
</el-form-item>
</el-col>
</el-row>
<div class="command-actions">
<el-button type="warning" plain :loading="previewLoading" @click="handlePreviewPayload">刷新双栏预览</el-button>
<el-button type="primary" :loading="dispatchLoading" @click="handleDispatchCommand">模拟下发并记日志</el-button>
</div>
</el-form>
</div>
<el-tabs class="workbench-tabs">
<el-tab-pane label="点位映射">
<div class="map-toolbar">
<div class="map-toolbar-tip">
<div class="map-toolbar-title">映射维护</div>
<div class="map-toolbar-desc">直接在工作台补齐本系统点位、第三方点位与转换脚本,减少来回切页。</div>
</div>
<div class="map-toolbar-actions">
<el-button type="primary" plain :disabled="!selectedEndpoint" @click="handleAddPointMap">新增映射</el-button>
<el-button type="success" plain :disabled="!selectedPointMap" @click="handleEditPointMap()">编辑当前</el-button>
<el-button type="danger" plain :disabled="!selectedPointMap" @click="handleDeletePointMap()">删除当前</el-button>
</div>
</div>
<el-table
ref="pointMapTableRef"
:data="pointMapList"
border
row-key="id"
highlight-current-row
class="inner-table"
@row-click="handlePointMapRowClick"
>
<el-table-column label="点位" min-width="170" show-overflow-tooltip>
<template #default="scope">
<div class="main-cell">{{ scope.row.monitorName || scope.row.monitorCode || '-' }}</div>
<div class="sub-cell">{{ scope.row.metricCode || '-' }}</div>
</template>
</el-table-column>
<el-table-column label="第三方点位" min-width="170" show-overflow-tooltip>
<template #default="scope">
<div class="main-cell">{{ scope.row.targetPointCode || scope.row.sourcePointCode || '-' }}</div>
<div class="sub-cell">{{ scope.row.dataFormat || '-' }}</div>
</template>
</el-table-column>
<el-table-column label="转换脚本" min-width="200" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.transformScript || '未配置转换脚本,默认直传' }}
</template>
</el-table-column>
<el-table-column label="状态" width="96" align="center">
<template #default="scope">
<el-tag :type="String(scope.row.isEnable) === '0' ? 'success' : 'info'" effect="light">
{{ String(scope.row.isEnable) === '0' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="138" align="center">
<template #default="scope">
<el-button link type="primary" @click.stop="handleEditPointMap(scope.row)">编辑</el-button>
<el-button link type="danger" @click.stop="handleDeletePointMap(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="命令日志">
<el-table :data="commandLogList" border class="inner-table">
<el-table-column label="时间" prop="createTime" min-width="170">
<template #default="scope">
{{ formatDateTime(scope.row.createTime) }}
</template>
</el-table-column>
<el-table-column label="点位" min-width="170" show-overflow-tooltip>
<template #default="scope">
<div class="main-cell">{{ scope.row.monitorName || scope.row.monitorCode || '-' }}</div>
<div class="sub-cell">{{ scope.row.commandType || '-' }}</div>
</template>
</el-table-column>
<el-table-column label="执行状态" width="110" align="center">
<template #default="scope">
<el-tag :type="String(scope.row.executeStatus).toUpperCase() === 'SUCCESS' ? 'success' : 'danger'" effect="light">
{{ String(scope.row.executeStatus || '-').toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="响应信息" prop="responseContent" min-width="240" show-overflow-tooltip />
</el-table>
</el-tab-pane>
</el-tabs>
</template>
</el-card>
</el-col>
</el-row>
<el-dialog :title="dialog.title" v-model="dialog.visible" width="720px" append-to-body>
<el-form ref="integrationEndpointFormRef" :model="form" :rules="rules" label-width="94px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="端点名称" prop="endpointName">
<el-input v-model="form.endpointName" placeholder="请输入端点名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="端点类型" prop="endpointType">
<el-select v-model="form.endpointType" placeholder="请选择端点类型" style="width: 100%">
<el-option v-for="item in endpointTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="访问模式" prop="accessMode">
<el-select v-model="form.accessMode" placeholder="请选择访问模式" style="width: 100%">
<el-option v-for="item in accessModeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择状态" style="width: 100%">
<el-option v-for="item in endpointStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="主机地址" prop="host">
<el-input v-model="form.host" placeholder="请输入主机地址" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="端口" prop="port">
<el-input-number v-model="form.port" :min="1" :max="65535" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="扩展配置" prop="configJson">
<el-input
v-model="form.configJson"
type="textarea"
:rows="6"
placeholder="请输入 JSON 格式扩展配置,如安全策略、串口波特率、寄存器偏移等"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" :loading="buttonLoading" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</template>
</el-dialog>
<el-dialog title="报文双栏对比预览" v-model="previewDialog.visible" width="1180px" append-to-body>
<div class="preview-meta">
<div class="preview-meta-main">
<el-tag effect="dark">{{ previewDialog.dataFormat || '-' }}</el-tag>
<span>{{ previewDialog.endpointName || '-' }} / {{ previewDialog.monitorName || '-' }}</span>
</div>
<div class="preview-meta-side">
<span>命令类型:{{ previewDialog.commandType || '-' }}</span>
<span>命令值:{{ previewDialog.commandValue || '-' }}</span>
</div>
</div>
<div class="preview-compare">
<div class="preview-pane" :class="{ active: previewDialog.dataFormat === 'JSON' }">
<div class="preview-pane-header">
<span>JSON 预览</span>
<el-tag size="small" effect="plain" :type="previewDialog.dataFormat === 'JSON' ? 'warning' : 'info'">
{{ previewDialog.dataFormat === 'JSON' ? '当前格式' : '对照格式' }}
</el-tag>
</div>
<pre class="preview-box">{{ previewDialog.jsonPayloadText || '暂无 JSON 预览内容' }}</pre>
</div>
<div class="preview-pane" :class="{ active: previewDialog.dataFormat === 'XML' }">
<div class="preview-pane-header">
<span>XML 预览</span>
<el-tag size="small" effect="plain" :type="previewDialog.dataFormat === 'XML' ? 'warning' : 'info'">
{{ previewDialog.dataFormat === 'XML' ? '当前格式' : '对照格式' }}
</el-tag>
</div>
<pre class="preview-box">{{ previewDialog.xmlPayloadText || '暂无 XML 预览内容' }}</pre>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="previewDialog.visible = false">关 闭</el-button>
</div>
</template>
</el-dialog>
<el-dialog :title="mapDialog.title" v-model="mapDialog.visible" width="760px" append-to-body>
<el-form ref="pointMapFormRef" :model="pointMapForm" :rules="pointMapRules" label-width="112px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="所属端点">
<el-input :model-value="selectedEndpointName" readonly />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数据格式" prop="dataFormat">
<el-radio-group v-model="pointMapForm.dataFormat">
<el-radio label="JSON">JSON</el-radio>
<el-radio label="XML">XML</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="点位名称" prop="monitorCode">
<el-tree-select
v-model="pointMapForm.monitorCode"
:data="monitorTreeOptions"
:props="monitorTreeProps"
placeholder="请选择点位"
filterable
check-strictly
style="width: 100%"
@change="handlePointMapMonitorChange"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="测量项编码">
<el-input v-model="pointMapForm.metricCode" readonly />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="第三方源点位">
<el-input v-model="pointMapForm.sourcePointCode" placeholder="请输入第三方源点位编码" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="第三方目标点位">
<el-input v-model="pointMapForm.targetPointCode" placeholder="请输入第三方目标点位编码" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="转换脚本" prop="transformScript">
<el-input
v-model="pointMapForm.transformScript"
type="textarea"
:rows="5"
placeholder="可填写表达式、脚本片段或转换说明;留空时工作台默认按原值透传"
/>
</el-form-item>
<el-form-item label="启用状态" prop="isEnable">
<el-switch v-model="pointMapForm.isEnable" active-value="0" inactive-value="1" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" :loading="mapSubmitting" @click="submitPointMapForm">保 存</el-button>
<el-button @click="closePointMapDialog">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="IntegrationEndpoint" lang="ts">
import { getCurrentInstance, nextTick } from 'vue';
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
import {
addIntegrationEndpoint,
delIntegrationEndpoint,
dispatchIntegrationCommand,
getIntegrationEndpoint,
getIntegrationWorkbenchDetail,
listIntegrationEndpoint,
previewIntegrationPayload,
testIntegrationConnection,
updateIntegrationEndpoint
} from '@/api/ems/base/integrationEndpoint';
import {
addIntegrationPointMap,
delIntegrationPointMap,
getIntegrationPointMap,
updateIntegrationPointMap
} from '@/api/ems/base/integrationPointMap';
import type {
IntegrationCommandDispatchForm,
IntegrationEndpointForm,
IntegrationEndpointQuery,
IntegrationEndpointVO,
IntegrationPayloadPreviewResult,
IntegrationWorkbenchDetail
} from '@/api/ems/base/integrationEndpoint/types';
import type { IntegrationPointMapForm } from '@/api/ems/base/integrationPointMap/types';
import type { ControlCommandLogVO, EmsTreeNode, IntegrationPointMapVO } from '@/api/ems/types';
import { parseTime } from '@/utils/ruoyi';
defineOptions({
name: 'IntegrationEndpointWorkbench'
} as any);
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const endpointTableRef = ref();
const pointMapTableRef = ref();
const queryFormRef = ref<ElFormInstance>();
const integrationEndpointFormRef = ref<ElFormInstance>();
const pointMapFormRef = ref<ElFormInstance>();
const buttonLoading = ref(false);
const loading = ref(false);
const workbenchLoading = ref(false);
const testLoading = ref(false);
const previewLoading = ref(false);
const dispatchLoading = ref(false);
const mapSubmitting = ref(false);
const showSearch = ref(true);
interface MonitorTreeNode extends EmsTreeNode {
label?: string;
value?: string | number;
children?: MonitorTreeNode[];
}
const endpointTypeOptions = [
{ label: 'OPCUA', value: 'OPCUA' },
{ label: 'MODBUS_TCP', value: 'MODBUS_TCP' },
{ label: 'MODBUS_RTU', value: 'MODBUS_RTU' },
{ label: 'HTTP_API', value: 'HTTP_API' }
];
const accessModeOptions = [
{ label: '客户端', value: 'CLIENT' },
{ label: '服务端', value: 'SERVER' },
{ label: 'TCP', value: 'TCP' },
{ label: 'RTU', value: 'RTU' }
];
const endpointStatusOptions = [
{ label: '可联调', value: 'ACTIVE' },
{ label: '停用', value: 'DISABLED' }
];
const integrationEndpointList = ref<IntegrationEndpointVO[]>([]);
const monitorTreeOptions = ref<MonitorTreeNode[]>([]);
const monitorInfoMap = ref<Record<string, { monitorName?: string; metricCode?: string }>>({});
const selectedEndpointId = ref<string | number | undefined>();
const workbenchDetail = ref<IntegrationWorkbenchDetail>({
endpoint: undefined,
pointMaps: [],
commandLogs: []
});
const previewDialog = reactive({
visible: false,
endpointName: '',
monitorName: '',
dataFormat: '',
commandType: '',
commandValue: '',
payloadText: '',
jsonPayloadText: '',
xmlPayloadText: ''
});
const dialog = reactive<DialogOption>({
visible: false,
title: ''
});
const mapDialog = reactive<DialogOption>({
visible: false,
title: ''
});
const initFormData: IntegrationEndpointForm = {
id: undefined,
endpointName: undefined,
endpointType: undefined,
accessMode: undefined,
host: undefined,
port: undefined,
configJson: undefined,
status: undefined
};
const form = ref<any>({ ...initFormData });
const createPointMapFormData = (): IntegrationPointMapForm => ({
id: undefined,
endpointId: undefined,
endpointName: undefined,
monitorCode: undefined,
monitorName: undefined,
metricCode: undefined,
sourcePointCode: undefined,
targetPointCode: undefined,
transformScript: undefined,
dataFormat: 'JSON',
isEnable: '0'
});
const pointMapForm = ref<IntegrationPointMapForm>(createPointMapFormData());
const monitorTreeProps = {
label: 'label',
value: 'value',
children: 'children'
};
const queryParams = reactive<IntegrationEndpointQuery>({
pageNum: 1,
pageSize: 999,
endpointName: undefined,
endpointType: undefined,
accessMode: undefined,
host: undefined,
status: undefined,
params: {}
});
const rules = reactive({
endpointName: [{ required: true, message: '端点名称不能为空', trigger: 'blur' }],
endpointType: [{ required: true, message: '端点类型不能为空', trigger: 'blur' }],
accessMode: [{ required: true, message: '访问模式不能为空', trigger: 'blur' }],
host: [{ required: true, message: '主机地址不能为空', trigger: 'blur' }]
});
const pointMapRules = reactive({
monitorCode: [{ required: true, message: '点位不能为空', trigger: 'change' }],
dataFormat: [{ required: true, message: '数据格式不能为空', trigger: 'change' }]
});
const commandForm = reactive<IntegrationCommandDispatchForm>({
endpointId: undefined,
mapId: undefined,
monitorCode: undefined,
metricCode: undefined,
commandType: 'WRITE',
commandValue: '1',
dataFormat: 'JSON'
});
const selectedEndpoint = computed(() => workbenchDetail.value.endpoint);
const pointMapList = computed(() => workbenchDetail.value.pointMaps || []);
const commandLogList = computed(() => workbenchDetail.value.commandLogs || []);
const selectedPointMap = computed(() => pointMapList.value.find((item) => item.id === commandForm.mapId));
const selectedEndpointName = computed(() => selectedEndpoint.value?.endpointName || '未选择端点');
const selectedEndpointTypeLabel = computed(() => selectedEndpoint.value?.endpointType || '未识别类型');
const selectedEndpointStatusLabel = computed(() => getEndpointStatusMeta(selectedEndpoint.value?.status).label);
const selectedMonitorName = computed(() => {
const pointMap = pointMapList.value.find((item) => item.id === commandForm.mapId);
return pointMap?.monitorName || pointMap?.monitorCode || '未选择映射对象';
});
const getEndpointStatusMeta = (status?: string) => {
const normalized = String(status || '')
.trim()
.toUpperCase();
if (!normalized || ['ACTIVE', 'ENABLE', 'ENABLED', '0'].includes(normalized)) {
return { label: '可联调', type: 'success' as const };
}
return { label: '停用', type: 'info' as const };
};
const formatDateTime = (value?: string | number | Date) => {
if (!value) {
return '-';
}
return parseTime(value as any, '{y}-{m}-{d} {h}:{i}:{s}');
};
const normalizeMonitorTree = (nodes: MonitorTreeNode[] = []): MonitorTreeNode[] =>
nodes.map((node) => {
const children = Array.isArray(node.children) ? normalizeMonitorTree(node.children) : [];
const label = node.monitorName || node.label || node.name || node.monitorCode || String(node.value ?? '');
const value = node.monitorCode || node.value || node.monitorId || node.objId || node.id;
const key = String(value ?? '');
if (key) {
monitorInfoMap.value[key] = {
monitorName: node.monitorName || label,
metricCode: node.metricCode as string | undefined
};
}
return {
...node,
label,
value,
children
};
});
const reset = () => {
form.value = { ...initFormData };
integrationEndpointFormRef.value?.resetFields();
};
const resetPointMapForm = () => {
pointMapForm.value = {
...createPointMapFormData(),
endpointId: selectedEndpoint.value?.id,
endpointName: selectedEndpoint.value?.endpointName
};
pointMapFormRef.value?.resetFields();
};
const resetCommandForm = () => {
const firstMap = pointMapList.value[0];
commandForm.endpointId = selectedEndpoint.value?.id;
commandForm.mapId = firstMap?.id;
commandForm.monitorCode = firstMap?.monitorCode;
commandForm.metricCode = firstMap?.metricCode;
commandForm.commandType = 'WRITE';
commandForm.commandValue = '1';
commandForm.dataFormat = String(firstMap?.dataFormat || 'JSON').toUpperCase();
};
const loadWorkbench = async (endpointId?: string | number) => {
if (!endpointId) {
workbenchDetail.value = { endpoint: undefined, pointMaps: [], commandLogs: [] };
return;
}
workbenchLoading.value = true;
try {
const response = await getIntegrationWorkbenchDetail(endpointId);
workbenchDetail.value = ((response as any).data ?? response) as IntegrationWorkbenchDetail;
selectedEndpointId.value = endpointId;
resetCommandForm();
await nextTick();
pointMapTableRef.value?.setCurrentRow?.(pointMapList.value[0] || null);
} finally {
workbenchLoading.value = false;
}
};
const syncSelectedEndpoint = async () => {
if (!integrationEndpointList.value.length) {
selectedEndpointId.value = undefined;
workbenchDetail.value = { endpoint: undefined, pointMaps: [], commandLogs: [] };
return;
}
const currentId = selectedEndpointId.value;
const matched = currentId ? integrationEndpointList.value.find((item) => item.id === currentId) : undefined;
const target = matched || integrationEndpointList.value[0];
await loadWorkbench(target.id);
endpointTableRef.value?.setCurrentRow?.(target);
};
/** 查询端点列表 */
const getList = async () => {
loading.value = true;
try {
const response = await listIntegrationEndpoint(queryParams);
integrationEndpointList.value = ((response as any).rows ?? (response as any).data ?? []) as IntegrationEndpointVO[];
await syncSelectedEndpoint();
} finally {
loading.value = false;
}
};
const handleQuery = () => {
getList();
};
const resetQuery = () => {
queryFormRef.value?.resetFields();
handleQuery();
};
const handleEndpointRowClick = async (row: IntegrationEndpointVO) => {
await loadWorkbench(row.id);
};
const handleMapChange = (mapId?: string | number) => {
const pointMap = pointMapList.value.find((item) => item.id === mapId);
commandForm.monitorCode = pointMap?.monitorCode;
commandForm.metricCode = pointMap?.metricCode;
commandForm.dataFormat = String(pointMap?.dataFormat || 'JSON').toUpperCase();
};
const handlePointMapMonitorChange = (monitorCode?: string | number) => {
const matched = monitorInfoMap.value[String(monitorCode || '')];
pointMapForm.value.monitorName = matched?.monitorName;
pointMapForm.value.metricCode = matched?.metricCode;
};
const handlePointMapRowClick = (row: IntegrationPointMapVO) => {
commandForm.mapId = row.id;
pointMapTableRef.value?.setCurrentRow?.(row);
handleMapChange(row.id);
};
const handleTestConnection = async () => {
if (!selectedEndpoint.value) {
return;
}
testLoading.value = true;
try {
const response = await testIntegrationConnection({ endpointId: selectedEndpoint.value.id });
const result = ((response as any).data ?? response) as any;
const checkedItems = (result.checkedItems || []).join('\n');
if (result.success) {
await (proxy as any)?.$alert(`${result.message}\n\n${checkedItems}`, result.title || '连接测试通过', { type: 'success' });
} else {
await (proxy as any)?.$alert(`${result.message}\n\n${checkedItems}`, result.title || '连接测试失败', { type: 'warning' });
}
} finally {
testLoading.value = false;
}
};
const handlePreviewPayload = async () => {
if (!selectedEndpoint.value) {
return;
}
if (!commandForm.mapId) {
proxy?.$modal.msgWarning('请先选择一条点位映射后再预览报文');
return;
}
previewLoading.value = true;
try {
commandForm.endpointId = selectedEndpoint.value.id;
const response = await previewIntegrationPayload(commandForm);
const result = ((response as any).data ?? response) as IntegrationPayloadPreviewResult;
previewDialog.endpointName = result.endpointName || '';
previewDialog.monitorName = result.monitorName || '';
previewDialog.dataFormat = result.dataFormat || '';
previewDialog.commandType = commandForm.commandType || '';
previewDialog.commandValue = commandForm.commandValue || '';
previewDialog.payloadText = result.payloadText || '';
previewDialog.jsonPayloadText = result.jsonPayloadText || result.payloadText || '';
previewDialog.xmlPayloadText = result.xmlPayloadText || result.payloadText || '';
previewDialog.visible = true;
} finally {
previewLoading.value = false;
}
};
const handleDispatchCommand = async () => {
if (!selectedEndpoint.value) {
return;
}
if (!commandForm.mapId) {
proxy?.$modal.msgWarning('请先选择一条点位映射后再执行命令下发');
return;
}
commandForm.endpointId = selectedEndpoint.value.id;
dispatchLoading.value = true;
try {
await dispatchIntegrationCommand(commandForm);
proxy?.$modal.msgSuccess('命令下发日志已生成');
await loadWorkbench(selectedEndpoint.value.id);
} finally {
dispatchLoading.value = false;
}
};
const cancel = () => {
dialog.visible = false;
reset();
};
const closePointMapDialog = () => {
mapDialog.visible = false;
resetPointMapForm();
};
const handleAdd = () => {
reset();
dialog.visible = true;
dialog.title = '新增对接端点';
};
const handleUpdate = async (row?: IntegrationEndpointVO) => {
reset();
const id = row?.id;
if (!id) {
return;
}
const response = await getIntegrationEndpoint(id);
form.value = {
...initFormData,
...((response as any).data ?? {})
};
dialog.visible = true;
dialog.title = '修改对接端点';
};
const handleAddPointMap = () => {
if (!selectedEndpoint.value?.id) {
return;
}
resetPointMapForm();
mapDialog.visible = true;
mapDialog.title = '新增点位映射';
};
const handleEditPointMap = async (row?: IntegrationPointMapVO) => {
const targetId = row?.id || selectedPointMap.value?.id;
if (!targetId) {
return;
}
resetPointMapForm();
const response = await getIntegrationPointMap(targetId);
pointMapForm.value = {
...createPointMapFormData(),
endpointId: selectedEndpoint.value?.id,
endpointName: selectedEndpoint.value?.endpointName,
...(((response as any).data ?? response) as IntegrationPointMapForm)
};
mapDialog.visible = true;
mapDialog.title = '编辑点位映射';
};
const submitPointMapForm = async () => {
if (!selectedEndpoint.value?.id) {
return;
}
const valid = await pointMapFormRef.value?.validate().catch(() => false);
if (!valid) {
return;
}
mapSubmitting.value = true;
try {
pointMapForm.value.endpointId = selectedEndpoint.value.id;
handlePointMapMonitorChange(pointMapForm.value.monitorCode);
if (pointMapForm.value.id) {
await updateIntegrationPointMap(pointMapForm.value);
proxy?.$modal.msgSuccess('映射修改成功');
} else {
await addIntegrationPointMap(pointMapForm.value);
proxy?.$modal.msgSuccess('映射新增成功');
}
mapDialog.visible = false;
await loadWorkbench(selectedEndpoint.value.id);
} finally {
mapSubmitting.value = false;
}
};
const handleDeletePointMap = async (row?: IntegrationPointMapVO) => {
const target = row || selectedPointMap.value;
if (!target?.id) {
return;
}
await proxy?.$modal.confirm(`是否确认删除映射“${target.monitorName || target.monitorCode || target.id}”?`);
await delIntegrationPointMap(target.id);
proxy?.$modal.msgSuccess('映射删除成功');
await loadWorkbench(selectedEndpoint.value?.id);
};
const submitForm = async () => {
const valid = await integrationEndpointFormRef.value?.validate().catch(() => false);
if (!valid) {
return;
}
buttonLoading.value = true;
try {
if (form.value.id) {
await updateIntegrationEndpoint(form.value);
proxy?.$modal.msgSuccess('修改成功');
} else {
await addIntegrationEndpoint(form.value);
proxy?.$modal.msgSuccess('新增成功');
}
dialog.visible = false;
await getList();
} finally {
buttonLoading.value = false;
}
};
const handleDelete = async (row?: IntegrationEndpointVO) => {
if (!row?.id) {
return;
}
await proxy?.$modal.confirm(`是否确认删除端点“${row.endpointName}”?`);
await delIntegrationEndpoint(row.id);
proxy?.$modal.msgSuccess('删除成功');
await getList();
};
onMounted(() => {
getMonitorInfoTree({
monitorTypeList: [5, 6, 7, 8, 9, 10]
} as any).then((response) => {
monitorInfoMap.value = {};
monitorTreeOptions.value = normalizeMonitorTree(((response as any).data ?? response ?? []) as MonitorTreeNode[]);
});
getList();
});
</script>
<style scoped lang="scss">
.workbench-page {
background:
radial-gradient(circle at top left, rgba(15, 104, 96, 0.08), transparent 28%), linear-gradient(180deg, #f4faf8 0%, #f5f4ef 56%, #f1f2ee 100%);
min-height: 100%;
}
.hero-card {
background:
linear-gradient(135deg, rgba(16, 76, 73, 0.97), rgba(24, 106, 100, 0.92)), linear-gradient(45deg, rgba(231, 177, 106, 0.16), transparent 42%);
border: none;
border-radius: 22px;
color: #eef8f4;
margin-bottom: 12px;
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(280px, 0.95fr);
gap: 18px;
}
.hero-kicker {
color: rgba(227, 247, 240, 0.72);
font-size: 12px;
letter-spacing: 0.22em;
margin-bottom: 10px;
}
.hero-title {
font-size: 30px;
font-weight: 700;
line-height: 1.15;
}
.hero-desc {
color: rgba(237, 250, 245, 0.86);
font-size: 14px;
line-height: 1.8;
margin-top: 12px;
}
.hero-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.hero-tag {
border-radius: 999px;
}
.hero-panel {
background: linear-gradient(180deg, rgba(244, 252, 248, 0.14), rgba(244, 252, 248, 0.06));
border: 1px solid rgba(224, 244, 236, 0.16);
border-radius: 18px;
height: 100%;
padding: 20px 22px;
}
.hero-panel-label {
color: rgba(227, 247, 240, 0.72);
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.hero-panel-value {
font-size: 22px;
font-weight: 600;
line-height: 1.4;
margin-top: 12px;
}
.hero-panel-foot {
color: rgba(232, 248, 242, 0.8);
font-size: 13px;
line-height: 1.7;
margin-top: 10px;
}
.query-card,
.summary-card,
.endpoint-card,
.workbench-card {
border: 1px solid rgba(42, 110, 102, 0.08);
border-radius: 18px;
box-shadow: 0 14px 30px rgba(16, 76, 73, 0.05);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.summary-label {
color: #637770;
font-size: 13px;
margin-bottom: 8px;
}
.summary-value {
color: #125451;
font-size: 28px;
font-weight: 700;
line-height: 1.1;
}
.summary-text {
font-size: 22px;
}
.summary-foot {
color: #8a9690;
font-size: 12px;
margin-top: 10px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title {
color: #104c49;
font-size: 18px;
font-weight: 600;
}
.card-subtitle {
color: #75817b;
font-size: 13px;
margin-top: 4px;
}
.header-actions {
display: flex;
gap: 8px;
}
.command-panel {
background: linear-gradient(180deg, rgba(242, 248, 246, 0.86), rgba(255, 255, 255, 0.98));
border: 1px solid rgba(42, 110, 102, 0.08);
border-radius: 16px;
margin-bottom: 16px;
padding: 16px;
}
.section-title {
color: #104c49;
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
}
.command-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.map-toolbar {
align-items: center;
display: flex;
gap: 14px;
justify-content: space-between;
margin-bottom: 12px;
}
.map-toolbar-title {
color: #104c49;
font-size: 15px;
font-weight: 600;
}
.map-toolbar-desc {
color: #7a8781;
font-size: 12px;
margin-top: 4px;
}
.map-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.main-cell {
color: #104c49;
font-weight: 600;
line-height: 20px;
}
.sub-cell {
color: #8a9690;
font-size: 12px;
line-height: 18px;
}
.json-summary {
color: #6d7e78;
}
.preview-meta {
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 12px;
}
.preview-meta-main,
.preview-meta-side {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.preview-meta-side {
color: #6d7e78;
font-size: 12px;
}
.preview-compare {
display: grid;
gap: 14px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.preview-pane {
background: linear-gradient(180deg, #f6fbfa 0%, #eff6f4 100%);
border: 1px solid rgba(42, 110, 102, 0.12);
border-radius: 18px;
overflow: hidden;
}
.preview-pane.active {
border-color: rgba(204, 126, 44, 0.38);
box-shadow: 0 14px 28px rgba(204, 126, 44, 0.1);
}
.preview-pane-header {
align-items: center;
background: rgba(16, 76, 73, 0.04);
color: #104c49;
display: flex;
font-size: 14px;
font-weight: 600;
justify-content: space-between;
padding: 12px 14px;
}
.preview-box {
background: #0f2325;
color: #d8f7ef;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.7;
margin: 0;
max-height: 420px;
overflow: auto;
padding: 18px;
white-space: pre-wrap;
word-break: break-word;
}
:deep(.inner-table .el-table__header-wrapper th) {
background: linear-gradient(180deg, #e6f3ef 0%, #dcedea 100%);
}
@media (max-width: 1200px) {
.hero-grid,
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.hero-grid,
.summary-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.card-header,
.header-actions,
.command-actions,
.map-toolbar,
.map-toolbar-actions,
.preview-meta {
align-items: stretch;
flex-direction: column;
}
.preview-compare {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
</style>