20、工业协议转换与数据采集中间件 (模拟) - /数据与物联网组件/protocol-converter-middleware

76个工业组件库示例汇总

工业协议转换与数据采集中间件

概述

这是一个交互式的 Web 组件,旨在模拟一个工业数据采集中间件的核心功能。该中间件的概念是连接到工厂中的多种不同品牌、不同协议的 PLC(可编程逻辑控制器),统一采集数据点(标签),并在一个集中的界面进行监控和记录日志。

请注意:这是一个高度简化的前端模拟演示,不涉及真实的协议转换或网络通信。所有 PLC 连接、数据值、状态变化和日志都是在浏览器端通过 JavaScript 模拟生成的。

主要功能

  • 连接管理:
    • 显示预设的多个 PLC 连接配置(模拟不同品牌如 Siemens, Rockwell, Mitsubishi 等及其常用协议 S7, EtherNet/IP, MELSEC 等)。
    • 实时展示每个连接的状态:DISCONNECTED (已断开), CONNECTING (连接中), CONNECTED (已连接), ERROR (错误)。状态会基于模拟逻辑动态变化。
    • 提供搜索功能,可以按连接名称、IP 地址或协议过滤连接列表。
    • (概念性) 包含一个"添加连接"按钮(当前禁用)。
  • 数据点监控:
    • 以表格形式集中展示从所有已连接的 PLC 中模拟采集到的数据点(标签)。
    • 表格列包括:标签名、当前值、来源 PLC、最新时间戳、数据质量状态。
    • 实时数据更新: 表格中数据点的值会定期模拟更新(数值型波动、布尔型随机翻转、字符串偶尔变化)。
    • 数据质量模拟: 数据质量状态 (GOOD, UNCERTAIN, BAD) 会模拟变化,例如连接断开或错误时质量变为 BAD,偶尔也会模拟 UNCERTAIN 状态。
    • 提供搜索功能,可以按标签名或当前值过滤表格内容。
    • 提供下拉菜单,可以按来源 PLC 过滤表格内容。
  • 事件日志:
    • 实时记录中间件运行过程中的关键事件。
    • 日志条目包含时间戳、日志级别(INFO, WARN, ERROR)和事件消息。
    • 记录的事件类型包括:中间件启动/初始化、尝试连接 PLC、连接成功/失败/断开、数据点添加、数据质量变化等。
    • 提供按日志级别 (INFO, WARN, ERROR) 过滤显示日志的功能。
    • 提供清除当前显示日志的功能。
  • 全局状态: 页眉区域显示中间件的整体连接状态(如:全部已连接、部分连接、连接中、错误、已断开)和状态指示灯。
  • 界面风格: 采用苹果科技风格,三栏布局,简洁清晰,支持响应式设计。

如何使用

  1. 打开页面: 在浏览器中打开 index.html
  2. 观察连接: 左侧"连接管理"面板显示模拟的 PLC 连接及其状态。观察状态指示灯和文字的变化(如从 DISCONNECTED 变为 CONNECTING,然后变为 CONNECTEDERROR)。
  3. 监控数据: 中间的"数据点监控"表格会显示从状态为 CONNECTED 的 PLC 中模拟读取的数据点。观察值的实时变化以及质量状态的变化。
  4. 过滤/搜索数据:
    • 在数据点表格上方的搜索框输入标签名或值的关键字进行过滤。
    • 使用下拉菜单选择特定的 PLC 名称,只显示该来源的数据点。
  5. 查看日志: 右侧"事件日志"面板实时滚动显示中间件的操作记录。
  6. 过滤日志: 点击日志面板上方的复选框 (INFO, WARN, ERROR) 来选择要显示的日志级别。
  7. 清除日志: 点击"清除日志"按钮可以清空当前显示的日志记录(内存中的日志条数有上限,旧日志会自动移除)。
  8. 搜索连接: 在连接管理面板的搜索框输入关键字过滤连接列表。

模拟细节

  • 连接配置: 组件初始化时会生成一组模拟的 PLC 连接,随机分配品牌、协议和 IP 地址,并关联一组模拟的数据点标签定义。
  • 连接状态转换:
    • DISCONNECTED 状态下,有一定几率尝试变为 CONNECTING
    • CONNECTING 状态下,有较大概率变为 CONNECTED,小概率变为 ERROR(模拟连接失败),否则保持 CONNECTING
    • CONNECTED 状态下,有很小几率变为 DISCONNECTED(模拟断线)或 ERROR(模拟通信异常)。
    • ERROR 状态下,有一定几率恢复为 DISCONNECTED(准备重试)。
  • 数据点生成: 当一个连接状态变为 CONNECTED 时,其关联的标签会被添加到中央数据点列表中,并赋予初始值和 GOOD 质量。
  • 数据点更新: 对于状态为 CONNECTED 的 PLC,其中间件会定期模拟更新其关联数据点的值。数值会小幅波动,布尔值有低概率翻转,字符串偶尔变化。
  • 数据质量模拟:
    • 连接断开 (DISCONNECTED) 或发生错误 (ERROR) 时,其对应的数据点质量会变为 BADUNCERTAIN,值显示为 #BAD#UNCERTAIN
    • 即使连接正常,数据点也有极小概率随机变为 UNCERTAINBAD(模拟读取失败或校验错误)。
    • 质量非 GOOD 的数据点也有一定概率恢复为 GOOD
  • 日志: 在状态转换、数据点添加/移除(或质量变化)、用户操作(清除日志)等时刻生成对应级别的日志条目。

文件结构

数据与物联网组件/protocol-converter-middleware/
├── index.html         # 组件的 HTML 结构
├── styles.css         # 组件的 CSS 样式 (苹果风格, 三栏响应式)
├── script.js          # 组件的 JavaScript 逻辑 (模拟连接, 数据更新, 日志)
└── README.md          # 本说明文件

技术栈

  • HTML5
  • CSS3 (使用了 CSS 变量, Grid 布局, Flexbox, 动画, 媒体查询)
  • JavaScript (原生 JS, 无外部库依赖)

效果展示

在这里插入图片描述

源码

index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>工业协议转换与数据采集中间件</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div class="middleware-container">
        <header class="main-header">
            <h1>工业协议转换与数据采集中间件 (模拟)</h1>
            <div class="header-status">
                <span class="status-indicator" id="globalStatusIndicator"></span>
                <span id="globalStatusText">初始化中...</span>
            </div>
        </header>

        <main class="main-content">
            <!-- Column 1: Connection Management -->
            <section class="connection-panel panel">
                <h2><i class="icon icon-connect"></i> 连接管理</h2>
                <div class="panel-toolbar">
                    <button class="action-button add-btn" disabled title="功能暂未实现"><i class="icon icon-plus"></i> 添加连接</button>
                    <input type="search" id="connectionSearch" placeholder="搜索连接...">
                </div>
                <ul id="connectionList" class="connection-list">
                    <li class="placeholder">加载连接配置中...</li>
                    <!-- Connection items populated by JS -->
                </ul>
            </section>

            <!-- Column 2: Data Point Monitoring -->
            <section class="data-panel panel">
                <h2><i class="icon icon-tags"></i> 数据点监控 (<span id="monitoredPointsCount">0</span>)</h2>
                 <div class="panel-toolbar">
                     <input type="search" id="dataPointSearch" placeholder="搜索标签名或值...">
                     <select id="dataSourceFilter" title="按数据源过滤">
                         <option value="all">所有源</option>
                         <!-- Options populated by JS -->
                     </select>
                 </div>
                <div class="data-point-table-container">
                    <table id="dataPointTable">
                        <thead>
                            <tr>
                                <th>标签名</th>
                                <th>当前值</th>
                                <th>来源PLC</th>
                                <th>时间戳</th>
                                <th>状态</th>
                            </tr>
                        </thead>
                        <tbody id="dataPointTBody">
                            <tr class="placeholder-row">
                                <td colspan="5">等待数据接入...</td>
                            </tr>
                            <!-- Data rows populated by JS -->
                        </tbody>
                    </table>
                </div>
            </section>

            <!-- Column 3: Event Log -->
            <section class="log-panel panel">
                <h2><i class="icon icon-log"></i> 事件日志</h2>
                <div class="panel-toolbar">
                     <button class="action-button clear-log-btn" id="clearLogBtn"><i class="icon icon-clear"></i> 清除日志</button>
                     <span class="log-level-filter">
                         <label><input type="checkbox" name="logLevel" value="INFO" checked> INFO</label>
                         <label><input type="checkbox" name="logLevel" value="WARN" checked> WARN</label>
                         <label><input type="checkbox" name="logLevel" value="ERROR" checked> ERROR</label>
                     </span>
                </div>
                <ul id="logList" class="log-list">
                    <li class="log-item info placeholder">中间件启动</li>
                    <!-- Log items populated by JS -->
                </ul>
            </section>
        </main>

        <footer class="main-footer">
            <p>&copy; 2024 工业中间件模拟系统. 概念演示.</p>
        </footer>
    </div>

    <script src="script.js"></script>
</body>
</html> 

styles.css

:root {
    --bg-color-light: #f9f9f9;
    --bg-color-container: #ffffff;
    --header-bg: #f5f5f7;
    --panel-bg: #ffffff;
    --border-color: #e1e1e1;
    --text-primary: #1d1d1f;
    --text-secondary: #515154;
    --text-label: #6e6e73;
    --accent-blue: #007aff;
    --accent-green: #34c759;
    --accent-orange: #ff9500;
    --accent-red: #ff3b30;
    --accent-grey: #8e8e93;
    --status-connecting: var(--accent-blue);
    --status-connected: var(--accent-green);
    --status-error: var(--accent-red);
    --status-disconnected: var(--accent-grey);
    --status-quality-good: var(--accent-green);
    --status-quality-uncertain: var(--accent-orange);
    --status-quality-bad: var(--accent-red);
    --log-info: var(--accent-blue);
    --log-warn: var(--accent-orange);
    --log-error: var(--accent-red);
    --list-item-hover-bg: #f0f0f0;
    --list-item-selected-bg: #e8f3ff; /* Keep selection subtle */
    --list-item-selected-text: var(--text-primary);
    --input-bg: #f0f2f5;
    --input-border: transparent;
    --input-focus-border: var(--accent-blue);
    --placeholder-text: #aaaaaa;
    --table-header-bg: #f9f9f9;
    --table-row-hover-bg: #f5faff;
    --shadow-color: rgba(0, 0, 0, 0.05);
    --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    --font-monospace: "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
    --border-radius: 8px;
    --border-radius-small: 4px;
    --transition-speed: 0.2s;
}

* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    font-family: var(--font-family);
    background-color: var(--bg-color-light);
    color: var(--text-primary);
    line-height: 1.4;
    overflow-x: hidden;
}

.middleware-container {
    max-width: 1800px;
    margin: 1rem auto;
    background-color: var(--bg-color-container);
    border-radius: var(--border-radius);
    box-shadow: 0 4px 12px var(--shadow-color);
    overflow: hidden;
    display: flex;
    flex-direction: column;
    height: calc(100vh - 2rem); /* Limit height */
    min-height: 650px; /* Minimum reasonable height */
}

/* Header */
.main-header {
    background-color: var(--header-bg);
    padding: 0.75rem 1.5rem;
    border-bottom: 1px solid var(--border-color);
    flex-shrink: 0;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.main-header h1 {
    font-size: 1.3rem;
    font-weight: 600;
    color: var(--text-primary);
}

.header-status {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.9rem;
    color: var(--text-secondary);
}

.status-indicator {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background-color: var(--status-disconnected);
    transition: background-color var(--transition-speed);
    animation: pulse-grey 2s infinite ease-in-out;
}

.status-indicator.connecting {
    background-color: var(--status-connecting);
    animation: pulse-blue 1.5s infinite ease-in-out;
}

.status-indicator.connected {
    background-color: var(--status-connected);
     animation: none; /* Solid green when connected */
}

.status-indicator.error {
    background-color: var(--status-error);
    animation: pulse-red 1s infinite ease-in-out;
}

@keyframes pulse-blue {
    0%, 100% { box-shadow: 0 0 0 0 rgba(0, 122, 255, 0.4); }
    50% { box-shadow: 0 0 0 5px rgba(0, 122, 255, 0); }
}

@keyframes pulse-red {
     0%, 100% { box-shadow: 0 0 0 0 rgba(255, 59, 48, 0.5); }
    50% { box-shadow: 0 0 0 5px rgba(255, 59, 48, 0); }
}

@keyframes pulse-grey {
    0%, 100% { opacity: 0.6; }
    50% { opacity: 1; }
}

/* Main Content Layout */
.main-content {
    flex-grow: 1;
    padding: 1rem;
    overflow: hidden;
    display: grid;
    grid-template-columns: 320px 1fr 450px; /* Connections | Data | Log */
    gap: 1rem;
}

/* Panels */
.panel {
    background-color: var(--panel-bg);
    border-radius: var(--border-radius);
    padding: 1rem;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    /* Optional subtle border */
    /* border: 1px solid var(--border-color); */
}

.panel h2 {
    font-size: 1.1rem;
    font-weight: 600;
    margin-bottom: 1rem;
    padding-bottom: 0.6rem;
    border-bottom: 1px solid var(--border-color);
    display: flex;
    align-items: center;
    color: var(--text-primary);
    flex-shrink: 0;
}

.panel h2 .icon {
    margin-right: 0.6rem;
    color: var(--accent-blue); /* Default icon color */
}

.panel h2 span {
    font-weight: normal;
    font-size: 0.9em;
    color: var(--text-secondary);
    margin-left: 0.3rem;
}

.panel-toolbar {
    margin-bottom: 0.75rem;
    display: flex;
    gap: 0.5rem;
    align-items: center;
    flex-shrink: 0;
    flex-wrap: wrap; /* Allow wrapping on smaller screens if needed */
}

/* Common Input/Button Styles */
input[type="search"],
select {
    padding: 0.5rem 0.75rem;
    font-size: 0.9rem;
    border: 1px solid var(--input-border);
    background-color: var(--input-bg);
    border-radius: var(--border-radius-small);
    outline: none;
    transition: border-color var(--transition-speed), box-shadow var(--transition-speed);
    color: var(--text-primary);
    height: 34px; /* Match button height */
}

input[type="search"] {
    flex-grow: 1; /* Allow search to take space */
}

input[type="search"]:focus,
select:focus {
    border-color: var(--input-focus-border);
    box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);
}

select {
   appearance: none;
   background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236e6e73'%3E%3Cpath fill-rule='evenodd' d='M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z'/%3E%3C/svg%3E");
   background-repeat: no-repeat;
   background-position: right 0.5rem center;
   background-size: 16px 16px;
   padding-right: 2rem; /* Make space for arrow */
}

.action-button {
    padding: 0 0.75rem;
    height: 34px;
    font-size: 0.9rem;
    border: 1px solid var(--border-color);
    background-color: #fff;
    color: var(--accent-blue);
    border-radius: var(--border-radius-small);
    cursor: pointer;
    transition: background-color var(--transition-speed), border-color var(--transition-speed);
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    white-space: nowrap;
}

.action-button:hover {
    background-color: #f8f8f8;
    border-color: #d1d1d1;
}

.action-button:active {
    background-color: #f0f0f0;
}

.action-button:disabled {
    color: var(--accent-grey);
    cursor: not-allowed;
    background-color: #f5f5f5;
    opacity: 0.7;
}

.action-button .icon {
     font-size: 1.1em;
}

/* Connection Panel */
.connection-list {
    list-style: none;
    overflow-y: auto;
    flex-grow: 1;
}

.connection-item {
    padding: 0.75rem 0.5rem;
    margin-bottom: 0.2rem;
    border-radius: var(--border-radius-small);
    cursor: default; /* Not selectable for now */
    transition: background-color var(--transition-speed);
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-size: 0.9rem;
    border-bottom: 1px solid #f0f0f0;
}

.connection-item:last-child {
    border-bottom: none;
}

/* .connection-item:hover { background-color: var(--list-item-hover-bg); } */

.connection-details {
    display: flex;
    flex-direction: column;
    flex-grow: 1;
    margin-right: 0.5rem;
}

.connection-name {
    font-weight: 500;
    color: var(--text-primary);
}

.connection-name .protocol {
    font-weight: normal;
    font-size: 0.8em;
    color: var(--text-label);
    margin-left: 0.3rem;
    background-color: #eee;
    padding: 0.1rem 0.3rem;
    border-radius: 3px;
}

.connection-info {
    font-size: 0.8rem;
    color: var(--text-secondary);
}

.connection-status {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.8rem;
    font-weight: 500;
    padding: 0.2rem 0.5rem;
    border-radius: var(--border-radius-small);
    flex-shrink: 0;
    min-width: 90px; /* Ensure consistent width */
    justify-content: center;
}

.connection-status .dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    display: inline-block;
}

.connection-status.connecting {
    color: var(--status-connecting);
    background-color: rgba(0, 122, 255, 0.1);
}
.connection-status.connecting .dot { background-color: var(--status-connecting); }

.connection-status.connected {
    color: var(--status-connected);
    background-color: rgba(52, 199, 89, 0.1);
}
.connection-status.connected .dot { background-color: var(--status-connected); }

.connection-status.error {
    color: var(--status-error);
    background-color: rgba(255, 59, 48, 0.1);
}
.connection-status.error .dot { background-color: var(--status-error); }

.connection-status.disconnected {
    color: var(--status-disconnected);
    background-color: rgba(142, 142, 147, 0.1);
}
.connection-status.disconnected .dot { background-color: var(--status-disconnected); }

/* Data Point Panel */
.data-panel {
    grid-column: span 1; /* Takes middle space */
}

.data-point-table-container {
    flex-grow: 1;
    overflow: auto; /* Scroll only the table container */
    border: 1px solid var(--border-color);
    border-radius: var(--border-radius-small);
}

#dataPointTable {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.85rem;
}

#dataPointTable th,
#dataPointTable td {
    padding: 0.6rem 0.75rem;
    text-align: left;
    border-bottom: 1px solid var(--border-color);
    white-space: nowrap;
}

#dataPointTable th {
    background-color: var(--table-header-bg);
    font-weight: 500;
    color: var(--text-label);
    position: sticky;
    top: 0;
    z-index: 1;
}

#dataPointTable tbody tr:hover {
    background-color: var(--table-row-hover-bg);
}

#dataPointTable td:nth-child(1) { /* Tag Name */
    font-weight: 500;
    font-family: var(--font-monospace);
    color: var(--text-primary);
}

#dataPointTable td:nth-child(2) { /* Value */
    font-weight: 600;
    font-family: var(--font-monospace);
}

#dataPointTable td:nth-child(3) { /* Source PLC */
    color: var(--text-secondary);
}

#dataPointTable td:nth-child(4) { /* Timestamp */
    font-size: 0.8rem;
    color: var(--text-secondary);
}

#dataPointTable td:nth-child(5) { /* Status */
    text-align: center;
}

.data-quality-indicator {
    display: inline-block;
    width: 10px;
    height: 10px;
    border-radius: 50%;
    margin-right: 0.3em;
}

.quality-good .data-quality-indicator { background-color: var(--status-quality-good); }
.quality-uncertain .data-quality-indicator { background-color: var(--status-quality-uncertain); }
.quality-bad .data-quality-indicator { background-color: var(--status-quality-bad); }

.quality-good { color: var(--status-quality-good); }
.quality-uncertain { color: var(--status-quality-uncertain); }
.quality-bad { color: var(--status-quality-bad); }

.placeholder-row td {
    text-align: center;
    color: var(--placeholder-text);
    font-style: italic;
    height: 100px; /* Give placeholder some height */
}

/* Log Panel */
.log-panel {
    grid-column: span 1; /* Takes last column space */
}

.log-level-filter {
    display: flex;
    gap: 0.75rem;
    margin-left: auto; /* Push filter to the right */
    font-size: 0.8rem;
}

.log-level-filter label {
    display: flex;
    align-items: center;
    gap: 0.2rem;
    cursor: pointer;
    color: var(--text-label);
}
.log-level-filter input[type="checkbox"] {
    margin-right: 0.1rem;
}

.log-list {
    list-style: none;
    overflow-y: auto;
    flex-grow: 1;
    font-family: var(--font-monospace);
    font-size: 0.8rem;
    line-height: 1.5;
}

.log-item {
    padding: 0.3rem 0.5rem;
    border-bottom: 1px dashed #f0f0f0;
    display: flex;
    gap: 0.75rem;
    align-items: flex-start;
}

.log-item:last-child {
    border-bottom: none;
}

.log-item .timestamp {
    color: var(--text-secondary);
    flex-shrink: 0;
    width: 60px; /* Fixed width for alignment */
}

.log-item .level {
    font-weight: 600;
    flex-shrink: 0;
    width: 45px; /* Fixed width for alignment */
    text-align: right;
    padding-right: 0.5rem;
}

.log-item.info .level { color: var(--log-info); }
.log-item.warn .level { color: var(--log-warn); }
.log-item.error .level { color: var(--log-error); }

.log-item .message {
    color: var(--text-secondary);
    flex-grow: 1;
    word-break: break-word;
}

/* Footer */
.main-footer {
    background-color: var(--header-bg);
    padding: 0.6rem 1.5rem;
    text-align: center;
    font-size: 0.8rem;
    color: var(--text-secondary);
    border-top: 1px solid var(--border-color);
    flex-shrink: 0;
}

/* Icons (Basic Placeholders) */
.icon::before {
    display: inline-block;
    font-weight: normal;
    font-style: normal;
    font-variant: normal;
    text-rendering: auto;
    -webkit-font-smoothing: antialiased;
    margin-right: 0.3em;
}
.icon-connect::before { content: "🔌"; }
.icon-tags::before { content: "🏷️"; }
.icon-log::before { content: "📜"; }
.icon-plus::before { content: "+"; font-weight: bold; } 
.icon-clear::before { content: "🗑️"; }

/* Placeholder */
.placeholder {
    color: var(--placeholder-text);
    font-style: italic;
    text-align: center;
    padding: 1rem;
    font-size: 0.9rem;
}

/* Responsive Design */
@media (max-width: 1400px) {
    .main-content {
        grid-template-columns: 280px 1fr 400px; /* Adjust column widths */
    }
}

@media (max-width: 1200px) {
     .main-content {
        grid-template-columns: 250px 1fr 350px; /* Further adjust */
    }
     .main-header h1 {
        font-size: 1.15rem;
    }
    .log-panel {
        font-size: 0.75rem; /* Smaller log font */
    }
}

@media (max-width: 992px) {
    .middleware-container {
        height: auto;
        min-height: 100vh;
        margin: 0.5rem;
    }
    .main-content {
        grid-template-columns: 1fr; /* Stack columns */
        grid-template-rows: auto auto auto; /* Auto rows */
        padding: 0.5rem;
        gap: 0.5rem;
    }
    .panel {
        padding: 0.75rem;
        overflow-y: visible; /* Allow panels to grow */
    }
    .connection-list,
    .log-list {
        max-height: 250px; /* Limit list height */
        overflow-y: auto;
    }
    .data-point-table-container {
        max-height: 350px; /* Limit table height */
    }
     .main-header {
        flex-direction: column;
        align-items: flex-start;
        gap: 0.5rem;
        padding: 0.75rem;
    }
    .header-status {
         align-self: flex-end;
    }
    .panel-toolbar {
        flex-direction: column;
        align-items: stretch;
    }
     .log-level-filter {
        justify-content: space-around;
        margin-left: 0;
        margin-top: 0.5rem;
    }
}

@media (max-width: 576px) {
     .main-header h1 {
        font-size: 1rem;
    }
    .panel h2 {
        font-size: 1rem;
    }
    #dataPointTable {
        font-size: 0.75rem; /* Smaller table font on mobile */
    }
     #dataPointTable th,
     #dataPointTable td {
        padding: 0.4rem 0.5rem;
    }
    .connection-list,
    .log-list {
        max-height: 200px;
    }
     .data-point-table-container {
        max-height: 300px;
    }
} 

script.js

// script.js - Industrial Protocol Converter & Data Acquisition Middleware Component

document.addEventListener('DOMContentLoaded', () => {
    // --- DOM Elements ---
    const globalStatusIndicator = document.getElementById('globalStatusIndicator');
    const globalStatusText = document.getElementById('globalStatusText');
    const connectionSearchInput = document.getElementById('connectionSearch');
    const connectionListUl = document.getElementById('connectionList');
    const dataPointSearchInput = document.getElementById('dataPointSearch');
    const dataSourceFilterSelect = document.getElementById('dataSourceFilter');
    const monitoredPointsCountSpan = document.getElementById('monitoredPointsCount');
    const dataPointTableBody = document.getElementById('dataPointTBody');
    const clearLogBtn = document.getElementById('clearLogBtn');
    const logListUl = document.getElementById('logList');
    const logLevelFilters = document.querySelectorAll('input[name="logLevel"]');

    // --- Simulation State & Parameters ---
    let connections = {};
    let dataPoints = {}; // Key: tagId, Value: { tagName, value, sourceId, sourceName, timestamp, quality }
    let logs = [];
    let connectionSearchTerm = '';
    let dataPointSearchTerm = '';
    let dataSourceFilter = 'all';
    let visibleLogLevels = ['INFO', 'WARN', 'ERROR'];
    let logCounter = 0;
    const MAX_LOG_ITEMS = 200;

    // Update intervals (in ms)
    const connectionUpdateInterval = 5000; // Check/update connection status
    const dataUpdateInterval = 2000; // Update data point values

    // --- Sample Data Generation ---
    function generateSampleConnections(count = 5) {
        const generatedConnections = {};
        const brands = ['Siemens', 'Rockwell', 'Mitsubishi', 'Omron', 'Schneider'];
        const protocols = { // Simplified mapping
            'Siemens': 'S7',
            'Rockwell': 'EtherNet/IP',
            'Mitsubishi': 'MELSEC',
            'Omron': 'FINS',
            'Schneider': 'Modbus TCP'
        };
        const baseIP = "192.168.1.";

        for (let i = 1; i <= count; i++) {
            const id = `CONN-${String(i).padStart(3, '0')}`;
            const brand = brands[Math.floor(Math.random() * brands.length)];
            const protocol = protocols[brand];
            const ip = `${baseIP}${10 + i}`;
            generatedConnections[id] = {
                id: id,
                name: `${brand} PLC ${i}`,
                brand: brand,
                protocol: protocol,
                ip: ip,
                status: 'disconnected', // Initial: disconnected, connecting, connected, error
                tags: generateSampleTags(id, brand, i, 5 + Math.floor(Math.random() * 10))
            };
        }
        return generatedConnections;
    }

    function generateSampleTags(connId, brand, lineNum, count = 10) {
        const tags = {};
        const dataTypes = ['DINT', 'REAL', 'BOOL', 'STRING'];
        const prefixes = {
            'Siemens': 'DB1.DBW',
            'Rockwell': `Line${lineNum}.Tag`,
            'Mitsubishi': 'D',
            'Omron': 'D',
            'Schneider': '%MW'
        };

        for (let i = 0; i < count; i++) {
            const dataType = dataTypes[Math.floor(Math.random() * dataTypes.length)];
            const address = `${prefixes[brand]}${100 + i * 2}`;
            const tagName = `${connId}.${dataType === 'BOOL' ? 'StatusBit' : 'Sensor'}_${i}`;
            tags[tagName] = {
                tagName: tagName,
                address: address,
                dataType: dataType,
                sourceId: connId,
                sourceName: `${brand} PLC ${lineNum}`
                // Initial value, quality, timestamp will be added dynamically
            };
        }
        return tags;
    }

    // --- Logging Utility ---
    function addLog(level, message) {
        const timestamp = new Date().toLocaleTimeString('zh-CN');
        const logEntry = {
            id: logCounter++,
            level: level.toUpperCase(),
            message: message,
            timestamp: timestamp
        };
        logs.push(logEntry);
        if (logs.length > MAX_LOG_ITEMS) {
            logs.shift(); // Remove oldest log
        }
        // Only render if the level is visible
        if (visibleLogLevels.includes(logEntry.level)) {
            renderSingleLog(logEntry, true); // Add to top
        }
    }

    // --- Initialization ---
    function initializeMiddleware() {
        addLog('info', '中间件服务启动中...');
        connections = generateSampleConnections();
        setupEventListeners();

        // Start simulation loops
        updateConnectionStatuses(); // Initial update
        setInterval(updateConnectionStatuses, connectionUpdateInterval);
        setInterval(updateDataPoints, dataUpdateInterval);

        renderConnectionList();
        renderDataSourceFilter();
        updateGlobalStatus();
        renderLogList(); // Render initial logs
        addLog('info', '中间件初始化完成。');
        console.log("Middleware Monitor Initialized", connections);
    }

    // --- Event Handlers ---
    function setupEventListeners() {
        connectionSearchInput.addEventListener('input', handleConnectionSearch);
        dataPointSearchInput.addEventListener('input', handleDataPointSearch);
        dataSourceFilterSelect.addEventListener('change', handleDataSourceFilter);
        clearLogBtn.addEventListener('click', handleClearLogs);
        logLevelFilters.forEach(input => {
            input.addEventListener('change', handleLogLevelFilterChange);
        });
    }

    function handleConnectionSearch(event) {
        connectionSearchTerm = event.target.value.toLowerCase();
        renderConnectionList();
    }

    function handleDataPointSearch(event) {
        dataPointSearchTerm = event.target.value.toLowerCase();
        renderDataPointTable();
    }

    function handleDataSourceFilter(event) {
        dataSourceFilter = event.target.value;
        renderDataPointTable();
    }

    function handleClearLogs() {
        logs = [];
        logListUl.innerHTML = ''; // Clear display
        addLog('info', '日志已清除。');
    }

    function handleLogLevelFilterChange() {
        visibleLogLevels = Array.from(logLevelFilters)
            .filter(input => input.checked)
            .map(input => input.value);
        renderLogList(); // Re-render logs based on new filter
    }

    // --- Simulation & Data Update Logic ---
    function updateConnectionStatuses() {
        let changed = false;
        Object.values(connections).forEach(conn => {
            const prevState = conn.status;
            const rand = Math.random();

            switch (conn.status) {
                case 'disconnected':
                    if (rand < 0.3) { // Chance to start connecting
                        conn.status = 'connecting';
                        addLog('info', `尝试连接 ${conn.name} (${conn.ip})...`);
                    }
                    break;
                case 'connecting':
                    if (rand < 0.7) { // Chance to connect successfully
                        conn.status = 'connected';
                        addLog('info', `${conn.name} 连接成功.`);
                        initializeDataPointsForConnection(conn);
                    } else if (rand < 0.85) { // Chance to fail
                        conn.status = 'error';
                        addLog('error', `${conn.name} 连接失败 (超时或配置错误).`);
                        removeDataPointsForConnection(conn);
                    } // Else: remains connecting
                    break;
                case 'connected':
                    if (rand < 0.03) { // Small chance to disconnect
                        conn.status = 'disconnected';
                        addLog('warn', `${conn.name} 连接已断开.`);
                        removeDataPointsForConnection(conn);
                    } else if (rand < 0.06) { // Small chance of error state
                         conn.status = 'error';
                         addLog('error', `${conn.name} 连接出现错误 (通信异常).`);
                         // Mark data as uncertain or bad? For now, just show error
                         setDataPointsQuality(conn.id, 'uncertain');
                    }
                    break;
                case 'error':
                    if (rand < 0.2) { // Chance to recover (retry implicitly)
                        conn.status = 'disconnected'; // Go back to disconnected to retry
                        addLog('info', `${conn.name} 从错误状态恢复,尝试重新连接.`);
                    }
                    break;
            }
            if (conn.status !== prevState) {
                changed = true;
            }
        });

        if (changed) {
            renderConnectionList();
            updateGlobalStatus();
            renderDataPointTable(); // Data points might have been added/removed/quality changed
        }
    }

    function initializeDataPointsForConnection(conn) {
        Object.values(conn.tags).forEach(tagDef => {
            if (!dataPoints[tagDef.tagName]) {
                 dataPoints[tagDef.tagName] = {
                     ...tagDef,
                     value: generateInitialValue(tagDef.dataType),
                     timestamp: new Date(),
                     quality: 'good'
                 };
                 addLog('info', `${conn.name} 添加数据点: ${tagDef.tagName}`);
            } else {
                 // If tag already exists but connection was re-established
                 dataPoints[tagDef.tagName].quality = 'good';
                 dataPoints[tagDef.tagName].timestamp = new Date();
            }
        });
        updateMonitoredPointsCount();
    }

     function removeDataPointsForConnection(conn) {
         Object.keys(conn.tags).forEach(tagName => {
             if (dataPoints[tagName]) {
                 // Instead of deleting, mark as bad quality
                 dataPoints[tagName].quality = 'bad';
                 dataPoints[tagName].value = '#BAD'; // Indicate bad value
                 dataPoints[tagName].timestamp = new Date();
                 addLog('warn', `数据点 ${tagName} (来自 ${conn.name}) 质量变为 BAD (连接断开).`);
                 // delete dataPoints[tagName]; // Option: remove completely
             }
         });
          // updateMonitoredPointsCount(); // Don't update count if just marking as bad
     }

     function setDataPointsQuality(connectionId, quality) {
         Object.values(dataPoints).forEach(dp => {
             if (dp.sourceId === connectionId) {
                 dp.quality = quality;
                 dp.timestamp = new Date();
                 if(quality !== 'good') dp.value = `#${quality.toUpperCase()}`;
             }
         });
     }

    function updateDataPoints() {
        let dataChanged = false;
        Object.values(dataPoints).forEach(dp => {
            const conn = connections[dp.sourceId];
            if (conn && conn.status === 'connected') {
                const oldValue = dp.value;
                const oldQuality = dp.quality;
                const rand = Math.random();

                // Simulate value change
                dp.value = generateNextValue(dp.value, dp.dataType);

                // Simulate quality change
                if (rand < 0.02 && dp.quality === 'good') {
                    dp.quality = 'uncertain';
                    addLog('warn', `数据点 ${dp.tagName} 质量变为 UNCERTAIN.`);
                } else if (rand < 0.005 && dp.quality === 'good') {
                    dp.quality = 'bad';
                     dp.value = '#BAD';
                    addLog('error', `数据点 ${dp.tagName} 质量变为 BAD (读取错误).`);
                } else if (dp.quality !== 'good' && rand < 0.3) {
                     // Chance to recover quality
                     dp.quality = 'good';
                }

                if (dp.value !== oldValue || dp.quality !== oldQuality) {
                     dp.timestamp = new Date();
                     dataChanged = true;
                }
            }
        });

        if (dataChanged) {
            renderDataPointTable();
        }
    }

    function generateInitialValue(dataType) {
        switch (dataType) {
            case 'DINT': return Math.floor(Math.random() * 1000);
            case 'REAL': return (Math.random() * 100).toFixed(2);
            case 'BOOL': return Math.random() < 0.5;
            case 'STRING': return `Status_${Math.random().toString(36).substring(2, 7)}`;
            default: return null;
        }
    }

    function generateNextValue(currentValue, dataType) {
        switch (dataType) {
            case 'DINT':
                const change = Math.floor((Math.random() - 0.4) * 10);
                return currentValue + change;
            case 'REAL':
                const floatChange = (Math.random() - 0.45) * 5;
                let newValue = parseFloat(currentValue) + floatChange;
                return newValue.toFixed(2);
            case 'BOOL':
                // Lower chance to flip bool value
                return Math.random() < 0.05 ? !currentValue : currentValue;
            case 'STRING':
                 // Less frequent string changes
                 return Math.random() < 0.02 ? `Status_${Math.random().toString(36).substring(2, 7)}` : currentValue;
            default: return currentValue;
        }
    }

    function updateGlobalStatus() {
        const total = Object.keys(connections).length;
        const connectedCount = Object.values(connections).filter(c => c.status === 'connected').length;
        const errorCount = Object.values(connections).filter(c => c.status === 'error').length;
        const connectingCount = Object.values(connections).filter(c => c.status === 'connecting').length;

        let statusClass = 'disconnected';
        let statusText = `已断开 (${total - connectedCount - errorCount - connectingCount}/${total})`;

        if (errorCount > 0) {
            statusClass = 'error';
            statusText = `错误 (${errorCount}/${total})`;
        } else if (connectingCount > 0) {
            statusClass = 'connecting';
            statusText = `连接中 (${connectingCount}/${total})...`;
        } else if (connectedCount === total && total > 0) {
            statusClass = 'connected';
            statusText = `全部已连接 (${connectedCount}/${total})`;
        } else if (connectedCount > 0) {
            statusClass = 'connected'; // Show connected even if not all are
            statusText = `部分连接 (${connectedCount}/${total})`;
        } else if (total === 0) {
             statusText = '无连接';
        }

        globalStatusIndicator.className = `status-indicator ${statusClass}`;
        globalStatusText.textContent = statusText;
    }

    function updateMonitoredPointsCount() {
        monitoredPointsCountSpan.textContent = Object.keys(dataPoints).length;
    }

    // --- Rendering Functions ---
    function renderConnectionList() {
        connectionListUl.innerHTML = ''; // Clear list
        const filteredConnections = Object.values(connections).filter(conn =>
            conn.name.toLowerCase().includes(connectionSearchTerm)
            || conn.ip.toLowerCase().includes(connectionSearchTerm)
            || conn.protocol.toLowerCase().includes(connectionSearchTerm)
        );

        if (filteredConnections.length === 0) {
            connectionListUl.innerHTML = '<li class="placeholder">未找到匹配连接</li>';
            return;
        }

        filteredConnections
            .sort((a, b) => a.name.localeCompare(b.name))
            .forEach(conn => {
                const li = document.createElement('li');
                li.className = 'connection-item';
                li.dataset.id = conn.id;
                li.innerHTML = `
                    <div class="connection-details">
                        <span class="connection-name">${conn.name} <span class="protocol">${conn.protocol}</span></span>
                        <span class="connection-info">${conn.ip}</span>
                    </div>
                    <span class="connection-status ${conn.status}">
                        <span class="dot"></span>
                        ${conn.status.toUpperCase()}
                    </span>
                `;
                connectionListUl.appendChild(li);
            });
    }

    function renderDataSourceFilter() {
        const sources = [...new Set(Object.values(connections).map(c => c.name))].sort();
        dataSourceFilterSelect.innerHTML = '<option value="all">所有源</option>'; // Reset
        sources.forEach(sourceName => {
             const option = document.createElement('option');
             option.value = sourceName;
             option.textContent = sourceName;
             dataSourceFilterSelect.appendChild(option);
        });
    }

    function renderDataPointTable() {
        dataPointTableBody.innerHTML = ''; // Clear table
        const filteredDataPoints = Object.values(dataPoints).filter(dp => {
            const searchTermLower = dataPointSearchTerm.toLowerCase();
            const matchesSearch = searchTermLower === ''
                || dp.tagName.toLowerCase().includes(searchTermLower)
                || String(dp.value).toLowerCase().includes(searchTermLower);
            const matchesSource = dataSourceFilter === 'all' || dp.sourceName === dataSourceFilter;
            return matchesSearch && matchesSource;
        });

        if (filteredDataPoints.length === 0) {
            dataPointTableBody.innerHTML = '<tr class="placeholder-row"><td colspan="5">无匹配数据点</td></tr>';
             updateMonitoredPointsCount(); // Reflect filtered count? Or total? Let's show total.
             // monitoredPointsCountSpan.textContent = filteredDataPoints.length; // Option: show filtered count
            return;
        }

        filteredDataPoints
             .sort((a, b) => a.tagName.localeCompare(b.tagName)) // Sort by tag name
             .forEach(dp => {
                const tr = document.createElement('tr');
                tr.dataset.tagId = dp.tagName;
                const qualityClass = `quality-${dp.quality}`;
                tr.innerHTML = `
                    <td>${dp.tagName}</td>
                    <td>${formatValue(dp.value, dp.dataType)}</td>
                    <td>${dp.sourceName}</td>
                    <td>${dp.timestamp.toLocaleTimeString('zh-CN', { hour12: false })}.${String(dp.timestamp.getMilliseconds()).padStart(3, '0')}</td>
                    <td><span class="${qualityClass}"><span class="data-quality-indicator"></span>${dp.quality.toUpperCase()}</span></td>
                `;
                dataPointTableBody.appendChild(tr);
            });
        // updateMonitoredPointsCount(); // Update total count in the header
    }

    function formatValue(value, dataType) {
        if (typeof value === 'boolean') {
            return value ? 'TRUE' : 'FALSE';
        }
        if (value === null || value === undefined) {
            return '--';
        }
        // Handle special quality strings
        if (typeof value === 'string' && value.startsWith('#')) {
             return `<span style="color: ${value === '#BAD' ? 'var(--status-quality-bad)' : 'var(--status-quality-uncertain)'}">${value}</span>`;
        }
        return String(value);
    }

    function renderLogList() {
        logListUl.innerHTML = ''; // Clear list
        const filteredLogs = logs.filter(log => visibleLogLevels.includes(log.level));

        if (filteredLogs.length === 0) {
             logListUl.innerHTML = '<li class="log-item placeholder">无匹配日志条目</li>';
             return;
        }

        filteredLogs.forEach(log => renderSingleLog(log, false)); // Add to bottom initially
        logListUl.scrollTop = logListUl.scrollHeight; // Scroll to bottom after initial render
    }

    function renderSingleLog(log, prepend = false) {
         const li = document.createElement('li');
         li.className = `log-item ${log.level.toLowerCase()}`;
         li.dataset.logId = log.id;
         li.innerHTML = `
            <span class="timestamp">${log.timestamp}</span>
            <span class="level">${log.level}</span>
            <span class="message">${log.message}</span>
         `;
         if (prepend) {
             logListUl.insertBefore(li, logListUl.firstChild);
             // Optional: Trim logs if prepending frequently to avoid infinite growth in view
             if (logListUl.children.length > MAX_LOG_ITEMS * 1.2) { // Keep slightly more than max logs in view
                  logListUl.removeChild(logListUl.lastChild);
             }
         } else {
             logListUl.appendChild(li);
         }
    }

    // --- Initial Call ---
    initializeMiddleware();
}); 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

地上一の鹅

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值