<修改> 1、调整webserver,更新mongoose库源文件;2、重新生成前端工程;3、利用mongoose中的功能打包前端工程进项目工程中,以c文件格式存在,最终编译成可执行文件一起打包,后续升级改动前端时,无需升级前端工程

This commit is contained in:
ypc 2026-06-10 16:07:38 +08:00
parent a3ae78e5e8
commit 46f9f4fe76
14 changed files with 12896 additions and 3264 deletions

View File

@ -17,6 +17,7 @@ if [ "$1" = "arm" ]; then
echo " ARM 交叉编译 开始" echo " ARM 交叉编译 开始"
echo "=====================================" echo "====================================="
make CROSS=arm clean make CROSS=arm clean
bash "$(dirname "$0")/pack_web.sh"
make CROSS=arm make CROSS=arm
else else
echo "=====================================" echo "====================================="
@ -24,6 +25,7 @@ else
echo "=====================================" echo "====================================="
rm -f "$(dirname "$0")/../test/RTU" rm -f "$(dirname "$0")/../test/RTU"
make clean make clean
bash "$(dirname "$0")/pack_web.sh"
make make
fi fi

File diff suppressed because it is too large Load Diff

28
release/pack_web.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/bash
# pack_web.sh — 将 test/web_root 前端文件打包成 C 字节数组
# 用法: ./pack_web.sh
# 输出: src/system/libweb_server/src/packed_fs.c
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PACK_SRC="$SCRIPT_DIR/tools/pack/pack.c"
PACK_BIN="/tmp/rtu_pack"
WEB_ROOT="$SCRIPT_DIR/../test/web_root"
OUTPUT="$SCRIPT_DIR/../src/system/libweb_server/src/packed_fs.c"
# 1. 编译打包工具
echo "[pack] compiling pack tool..."
gcc -O2 -o "$PACK_BIN" "$PACK_SRC"
# 2. 收集前端文件
FILES=()
cd "$WEB_ROOT"
for f in $(find . -type f \( -name "*.html" -o -name "*.css" -o -name "*.js" \) ! -name "*_old*" | sort); do
FILES+=("$f")
done
echo "[pack] packing ${#FILES[@]} files..."
for f in "${FILES[@]}"; do echo " $f"; done
# 3. 生成 packed_fs.c
"$PACK_BIN" -s "./" "${FILES[@]}" > "$OUTPUT"
echo "[pack] done: $OUTPUT ($(wc -c < "$OUTPUT") bytes)"

71
release/tools/pack/pack.c Normal file
View File

@ -0,0 +1,71 @@
/*
* pack.c mongoose
*
* : gcc -o pack pack.c
* : ./pack [-s strip_prefix] file1 file2 ... > packed_fs.c
*
* C
* - struct mg_mem_file
* -
* - mg_packed_files[]
* - mg_unpack() / mg_unlist()
*/
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
int main(int argc, char *argv[]) {
int i, j, ch;
const char *strip_prefix = "";
printf("// Auto-generated — DO NOT EDIT\n");
printf("#include \"mongoose.h\"\n");
printf("\n");
for (i = 1; i < argc; i++) {
if (strcmp(argv[i], "-s") == 0) {
strip_prefix = argv[++i];
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
fprintf(stderr, "Usage: %s [-s STRIP_PREFIX] files...\n", argv[0]);
exit(EXIT_FAILURE);
} else {
char ascii[16];
FILE *fp = fopen(argv[i], "rb");
if (fp == NULL) {
fprintf(stderr, "Cannot open [%s]: %s\n", argv[i], strerror(errno));
exit(EXIT_FAILURE);
}
printf("static const unsigned char v%d[] = {\n", i);
for (j = 0; (ch = fgetc(fp)) != EOF; j++) {
if (j == (int) sizeof(ascii)) {
printf(" // %.*s\n", j, ascii);
j = 0;
}
ascii[j] = (char) ((ch >= ' ' && ch <= '~' && ch != '\\') ? ch : '.');
printf(" %3u,", ch);
}
printf(" 0 // %.*s\n};\n", j, ascii);
fclose(fp);
}
}
printf("\nconst struct mg_mem_file mg_packed_files[] = {\n");
for (i = 1; i < argc; i++) {
struct stat st;
const char *name = argv[i];
size_t n = strlen(strip_prefix);
if (strcmp(argv[i], "-s") == 0) { i++; continue; }
stat(argv[i], &st);
if (strncmp(name, strip_prefix, n) == 0) name += n;
printf(" {\"/%s\", v%d, sizeof(v%d) - 1, %lu},\n",
name, i, i, (unsigned long) st.st_mtime);
}
printf(" {NULL, NULL, 0, 0}\n");
printf("};\n");
return EXIT_SUCCESS;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,8 @@
#include "myFunc.h" #include "myFunc.h"
#include "mongoose.h" #include "mongoose.h"
#define USE_PACKED_FS
static const char *s_listen_on = "http://0.0.0.0:8000"; static const char *s_listen_on = "http://0.0.0.0:8000";
static const char *s_update_ws = "/ws"; static const char *s_update_ws = "/ws";
@ -82,7 +84,12 @@ LOCAL void web_server_task(struct mg_connection *c, int ev, void *p_data)
} }
else else
{ {
#ifdef USE_PACKED_FS
mg_mem_files = mg_packed_files;
struct mg_http_serve_opts opts = {.root_dir = "", .fs = &mg_fs_packed};
#else
struct mg_http_serve_opts opts = {.root_dir = g_web_root.c_str()}; struct mg_http_serve_opts opts = {.root_dir = g_web_root.c_str()};
#endif
mg_http_serve_dir(c, (mg_http_message *)p_data, &opts); mg_http_serve_dir(c, (mg_http_message *)p_data, &opts);
} }
} }
@ -173,6 +180,7 @@ LOCAL int web_server_init()
} }
g_web_root = std::string(proc_dir) + s_web_root; g_web_root = std::string(proc_dir) + s_web_root;
mg_log_set(MG_LL_ERROR);
mg_mgr_init(&mgr); mg_mgr_init(&mgr);
mg_http_listen(&mgr, s_listen_on, web_server_task, NULL); mg_http_listen(&mgr, s_listen_on, web_server_task, NULL);

View File

@ -807,6 +807,18 @@ LOCAL int make_param_signal_json(stru_ws_session &s, cJSON *root, bool &has_chan
cJSON_AddItemToArray(setting_zone_arr, setting_zone_item); cJSON_AddItemToArray(setting_zone_arr, setting_zone_item);
} }
std::string combined_val;
for(uint32_t j = 0; j < p_signal->vec_p_data.size(); j++)
{
combined_val += dc_get_signal_val(p_signal->vec_p_data[j], p_signal->data_type);
combined_val += ",";
}
if(combined_val != p_signal->last_val)
{
p_signal->last_val = combined_val;
has_change = true;
}
cJSON_AddItemToArray(param_arr, item); cJSON_AddItemToArray(param_arr, item);
} }

540
test/web_root/css/style.css Normal file
View File

@ -0,0 +1,540 @@
/* === Reset & Base === */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--nav-bg: #1a1a2e;
--nav-text: #e0e0e0;
--primary: #4CAF50;
--primary-hover: #43A047;
--danger: #f44336;
--danger-hover: #e53935;
--info: #2196F3;
--info-hover: #1E88E5;
--bg: #f0f2f5;
--card-bg: #ffffff;
--border: #e0e0e0;
--text: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--shadow: 0 2px 8px rgba(0,0,0,0.08);
--radius: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
}
/* === Navigation Sidebar === */
#nav-sidebar {
width: 220px;
min-width: 220px;
background: var(--nav-bg);
color: var(--nav-text);
display: flex;
flex-direction: column;
min-height: 100vh;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transition: transform 0.3s;
}
#nav-sidebar .nav-header {
padding: 20px 16px;
font-size: 16px;
font-weight: 700;
border-bottom: 1px solid rgba(255,255,255,0.1);
text-align: center;
letter-spacing: 1px;
}
#nav-sidebar .nav-status {
padding: 10px 16px;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.nav-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.nav-status-dot.connected { background: #4CAF50; box-shadow: 0 0 6px #4CAF50; }
.nav-status-dot.disconnected { background: #f44336; box-shadow: 0 0 6px #f44336; }
.nav-status-dot.connecting { background: #FF9800; animation: pulse 1s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
#nav-sidebar nav { flex: 1; padding: 8px 0; }
#nav-sidebar nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
color: var(--nav-text);
text-decoration: none;
font-size: 14px;
transition: background 0.2s;
border-left: 3px solid transparent;
}
#nav-sidebar nav a:hover { background: rgba(255,255,255,0.08); }
#nav-sidebar nav a.active { background: rgba(255,255,255,0.12); border-left-color: var(--primary); }
#nav-sidebar .nav-footer {
padding: 12px 16px;
border-top: 1px solid rgba(255,255,255,0.08);
}
#nav-sidebar .nav-footer button {
width: 100%;
padding: 8px;
background: rgba(255,255,255,0.1);
color: var(--nav-text);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
#nav-sidebar .nav-footer button:hover { background: rgba(255,255,255,0.18); }
/* === Main Content === */
#main-content {
margin-left: 220px;
flex: 1;
min-height: 100vh;
padding: 24px;
}
.page { display: none; }
.page.active { display: block; }
/* === Login Page (no sidebar) === */
body.login-page #nav-sidebar { display: none; }
body.login-page #main-content { margin-left: 0; display: flex; align-items: center; justify-content: center; }
.login-card {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
padding: 48px 40px;
width: 400px;
max-width: 90vw;
text-align: center;
}
.login-card h1 {
font-size: 22px;
color: var(--nav-bg);
margin-bottom: 4px;
}
.login-card .login-subtitle {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 32px;
}
.login-card .form-group {
margin-bottom: 16px;
text-align: left;
}
.login-card label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.login-card input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s;
}
.login-card input:focus { outline: none; border-color: var(--primary); }
.login-card input:disabled {
background: #f5f5f5;
color: var(--text-muted);
cursor: not-allowed;
}
.login-card .login-btn {
width: 100%;
padding: 12px;
background: var(--primary);
color: #fff;
border: none;
border-radius: 4px;
font-size: 15px;
cursor: pointer;
margin-top: 8px;
transition: background 0.2s;
}
.login-card .login-btn:hover { background: var(--primary-hover); }
.login-card .login-hint {
font-size: 12px;
color: var(--text-muted);
margin-top: 16px;
}
/* === Dashboard === */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
@media (max-width: 1200px) { .dashboard-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 768px) { .dashboard-grid { grid-template-columns: 1fr; } }
.dash-card {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 32px 24px;
text-align: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--text);
display: block;
}
.dash-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0,0,0,0.14);
}
.dash-card .card-icon { font-size: 36px; margin-bottom: 12px; }
.dash-card .card-title {
font-size: 17px;
font-weight: 600;
margin-bottom: 4px;
}
.dash-card .card-desc {
font-size: 13px;
color: var(--text-muted);
}
.dash-card.out { border-top: 4px solid #FF9800; }
.dash-card.in { border-top: 4px solid #2196F3; }
.dash-card.yk { border-top: 4px solid #f44336; }
.dash-card.ao { border-top: 4px solid #4CAF50; }
.dash-card.param { border-top: 4px solid #9C27B0; }
.dash-card.monitor { border-top: 4px solid #607D8B; }
/* === Page Header === */
.page-header {
margin-bottom: 20px;
}
.page-header h2 {
font-size: 20px;
color: var(--nav-bg);
}
.page-header p {
font-size: 13px;
color: var(--text-muted);
margin-top: 2px;
}
/* === Toolbar / Form Row === */
.toolbar {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 16px 20px;
margin-bottom: 16px;
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: flex-end;
}
.toolbar .field { display: flex; flex-direction: column; gap: 4px; }
.toolbar .field label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
}
.toolbar input, .toolbar select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 14px;
}
.toolbar input:focus, .toolbar select:focus { outline: none; border-color: var(--primary); }
.toolbar input[type="text"] { width: 260px; }
.toolbar select { width: 130px; }
/* === Buttons === */
.btn {
padding: 8px 18px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-info { background: var(--info); color: #fff; }
.btn-info:hover { background: var(--info-hover); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: var(--danger-hover); }
.btn-outline {
background: #fff;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-outline:hover { background: #f5f5f5; }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn-success { background: #4CAF50; color: #fff; }
.btn-success:hover { background: #43A047; }
.btn-warn { background: #FF9800; color: #fff; }
.btn-warn:hover { background: #F57C00; }
/* === Table === */
.table-card {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.table-wrapper {
max-height: 58vh;
overflow-y: auto;
}
.signal-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.signal-table thead th {
position: sticky;
top: 0;
background: #2c3e50;
color: #fff;
padding: 10px 12px;
text-align: left;
z-index: 10;
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.signal-table tbody td {
padding: 9px 12px;
border-bottom: 1px solid #f0f0f0;
}
.signal-table tbody tr:hover { background: #f8f9fa; }
.signal-table .val-input {
width: 100px;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 3px;
font-size: 13px;
}
.signal-table .val-input:focus { outline: none; border-color: var(--primary); }
.signal-table .empty-row td {
text-align: center;
color: var(--text-muted);
padding: 40px;
font-size: 14px;
}
/* === Expandable Row (Param/定值) === */
.expand-row { cursor: pointer; }
.expand-row:hover { background: #f0f4ff !important; }
.expand-icon { display: inline-block; transition: transform 0.2s; font-size: 12px; margin-right: 4px; }
.expand-icon.open { transform: rotate(90deg); }
.zone-sub-row td { padding: 0 !important; border-bottom: none !important; }
.zone-sub-table {
width: 100%;
border-collapse: collapse;
background: #f8f9fb;
border-left: 3px solid #9C27B0;
}
.zone-sub-table thead th {
background: #eceff1;
color: #555;
font-size: 11px;
padding: 7px 10px;
position: static;
}
.zone-sub-table tbody td {
padding: 7px 10px;
font-size: 12px;
border-bottom: 1px solid #e8e8e8;
}
/* === Monitor Page === */
.monitor-stats {
background: var(--card-bg);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 12px 20px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
}
.monitor-stats .stat-label { color: var(--text-muted); }
.monitor-stats .stat-value { font-weight: 600; }
.monitor-log {
background: #1e1e2e;
color: #cdd6f4;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 8px 0;
height: 68vh;
overflow-y: auto;
font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace;
font-size: 12px;
}
.monitor-empty {
padding: 40px;
text-align: center;
color: #6c7086;
font-size: 13px;
}
/* 每条记录 */
.mon-item {
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.mon-item.open {
background: rgba(255,255,255,0.03);
}
.mon-item-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
cursor: pointer;
transition: background 0.15s;
}
.mon-item-head:hover {
background: rgba(255,255,255,0.05);
}
.mon-time { color: #6c7086; white-space: nowrap; font-size: 11px; }
.mon-dir {
font-weight: 700;
white-space: nowrap;
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
}
.mon-dir.mon-up { color: #a6e3a1; background: rgba(166,227,161,0.12); }
.mon-dir.mon-down { color: #89b4fa; background: rgba(137,180,250,0.12); }
.mon-addr {
color: #9399b2;
white-space: nowrap;
font-size: 11px;
}
.mon-summary {
color: #cdd6f4;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.mon-expand-icon {
color: #6c7086;
font-size: 10px;
white-space: nowrap;
min-width: 14px;
text-align: center;
}
/* 展开的详情 */
.mon-item-body {
padding: 0 16px 12px 16px;
}
.mon-item-body pre {
background: rgba(0,0,0,0.25);
color: #cdd6f4;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 11px;
line-height: 1.6;
margin: 0;
border-left: 3px solid #585b70;
}
/* === Utility === */
.hidden { display: none !important; }
/* === Responsive === */
@media (max-width: 768px) {
#nav-sidebar { width: 180px; min-width: 180px; }
#main-content { margin-left: 180px; padding: 16px; }
.toolbar { flex-direction: column; }
.toolbar input[type="text"] { width: 100%; }
.dashboard-grid { grid-template-columns: 1fr; }
.signal-table { font-size: 12px; }
.signal-table thead th, .signal-table tbody td { padding: 6px 8px; }
}

View File

@ -1,294 +1,38 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XTU WEB DEBUG - Signal Monitor</title> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<style> <meta http-equiv="Pragma" content="no-cache">
body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; } <meta http-equiv="Expires" content="0">
.container { max-width: 1400px; margin: 0 auto; } <title>RTU 远程终端监控系统</title>
h1 { color: #333; text-align: center; } <link rel="stylesheet" href="css/style.css?v=2">
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
input, select { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.saddr-input { width: 280px; }
select { width: 140px; }
button { padding: 8px 16px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; }
button.secondary { background: #2196F3; }
#status { padding: 6px 12px; border-radius: 20px; font-weight: bold; }
.status-connected { background: #4CAF50; color: white; }
.status-disconnected { background: #f44336; color: white; }
#log { background: #f8f9fa; height: 150px; padding: 10px; overflow-y: auto; font-family: monospace; font-size: 12px; }
.log-entry { margin: 4px 0; }
.log-receive { color: #2196F3; }
.log-send { color: #4CAF50; }
.log-error { color: #f44336; }
.table-container { margin-bottom: 20px; }
.table-title { font-size: 16px; font-weight: bold; margin-bottom: 8px; color: #2c3e50; }
.table-wrapper {
max-height: 320px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 4px;
}
.signalTable { width: 100%; border-collapse: collapse; background: white; }
.signalTable th {
position: sticky;
top: 0;
background: #2c3e50;
color: white;
padding: 12px;
text-align: left;
z-index: 10;
}
.signalTable td { padding: 10px; border-bottom: 1px solid #eee; }
.signalTable tr:hover { background: #f5f5f5; }
.form-row { display: flex; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; align-items: flex-end; }
.form-group { display: flex; flex-direction: column; }
</style>
</head> </head>
<body> <body>
<div class="container">
<h1>XTU WEB DEBUG - Signal Monitor</h1>
<div class="card"> <!-- 侧边导航栏(登录页隐藏) -->
<div class="form-row"> <aside id="nav-sidebar">
<div class="form-group"> <div class="nav-header">RTU 监控系统</div>
<label>saddr *</label> <div class="nav-status">
<input id="saddr" class="saddr-input" placeholder="iec.run_cnt"> <span class="nav-status-dot disconnected" id="nav-status-dot"></span>
</div> <span id="nav-status-text">已断开</span>
<div class="form-group">
<label>signal_type *</label>
<select id="signal_type">
<option value="out">out</option>
<option value="in">in</option>
<option value="yk">yk</option>
<option value="ao">ao</option>
<option value="param">param</option>
</select>
</div>
<div class="form-group">
<label>curd *</label>
<select id="curd">
<option value="add">add</option>
<option value="set">set</option>
<option value="del">del</option>
</select>
</div>
<div class="form-group">
<label>setting_zone</label>
<input id="setting_zone" value="0" placeholder="0">
</div>
<div class="form-group">
<label>signal_data</label>
<input id="signal_data" placeholder="(can empty)">
</div>
</div>
<button onclick="sendSignal()">Send</button>
<button onclick="clearForm()" class="secondary">Clear Form</button>
<button onclick="clearAllTables()" class="secondary">Clear All Tables</button>
</div> </div>
<nav id="nav-links"></nav>
<div class="card"> <div class="nav-footer">
WebSocket Status: <span id="status" class="status-disconnected">Disconnected</span> <button id="nav-logout">退出登录</button>
<button onclick="connectWS()" class="secondary">Connect</button>
</div> </div>
</aside>
<div class="card"> <!-- 主内容区 -->
<div class="table-container"> <main id="main-content">
<div class="table-title">OUT 信号</div> <div id="page-container"></div>
<div class="table-wrapper"> </main>
<table class="signalTable">
<thead><tr><th>#</th><th>saddr</th><th>desc</th><th>数据类型</th><th>val</th></tr></thead>
<tbody id="outBody"></tbody>
</table>
</div>
</div>
<div class="table-container"> <!-- JS 依赖加载(顺序依赖) -->
<div class="table-title">IN 信号</div> <script src="js/monitor.js"></script>
<div class="table-wrapper"> <script src="js/ws-client.js"></script>
<table class="signalTable"> <script src="js/pages.js"></script>
<thead><tr><th>#</th><th>saddr</th><th>desc</th><th>数据类型</th><th>val</th></tr></thead> <script src="js/app.js"></script>
<tbody id="inBody"></tbody>
</table>
</div>
</div>
<div class="table-container">
<div class="table-title">YK 信号</div>
<div class="table-wrapper">
<table class="signalTable">
<thead><tr><th>#</th><th>saddr</th><th>desc</th><th>数据类型</th><th>val</th><th>ctrl_type</th></tr></thead>
<tbody id="ykBody"></tbody>
</table>
</div>
</div>
<div class="table-container">
<div class="table-title">AO 信号</div>
<div class="table-wrapper">
<table class="signalTable">
<thead><tr><th>#</th><th>saddr</th><th>desc</th><th>数据类型</th><th>val</th><th>ctrl_type</th><th>min</th><th>max</th><th>step</th><th>unit</th><th>default</th></tr></thead>
<tbody id="aoBody"></tbody>
</table>
</div>
</div>
<div class="table-container">
<div class="table-title">PARAM 信号(当前定值区:<span id="currentZone">0</span></div>
<div class="table-wrapper">
<table class="signalTable">
<thead><tr><th>#</th><th>saddr</th><th>desc</th><th>数据类型</th><th>val</th><th>ctrl_type</th><th>min</th><th>max</th><th>step</th><th>unit</th><th>default_val</th></tr></thead>
<tbody id="paramBody"></tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div id="log"></div>
</div>
</div>
<script>
let ws = null;
const tableBodies = {
out: document.getElementById('outBody'),
in: document.getElementById('inBody'),
yk: document.getElementById('ykBody'),
ao: document.getElementById('aoBody'),
param: document.getElementById('paramBody')
};
const ctrlTypeMap = { 0: "只读", 1: "直控", 2: "选控" };
const log = document.getElementById('log');
const status = document.getElementById('status');
const currentZoneSpan = document.getElementById('currentZone');
function addLog(msg, type = 'info') {
const t = new Date().toLocaleTimeString();
log.innerHTML += `<div class="log-entry"><span>[${t}]</span> <span class="log-${type}">${msg}</span></div>`;
log.scrollTop = log.scrollHeight;
}
function connectWS() {
if (ws) return;
const url = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
ws = new WebSocket(url);
ws.onopen = () => { status.textContent = 'Connected'; status.className = 'status-connected'; addLog('connected', 'info'); };
ws.onclose = () => { status.textContent = 'Disconnected'; status.className = 'status-disconnected'; ws = null; setTimeout(connectWS, 3000); };
ws.onerror = () => addLog('error', 'error');
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
addLog("received data", "receive");
parseData(data);
} catch (e) {
addLog("parse data error: " + e.message, "error");
}
};
}
function parseData(root) {
const setting_zone = document.getElementById('setting_zone').value.trim();
currentZoneSpan.textContent = setting_zone;
const types = ['out', 'in', 'yk', 'ao', 'param'];
types.forEach(type => {
const arr = root[type];
const body = tableBodies[type];
body.innerHTML = '';
if (!Array.isArray(arr)) return;
arr.forEach((item, index) => {
if (!item.saddr) return;
if (type === 'param') {
const zoneList = item.setting_zone_list || [];
const match = zoneList.find(z => z.id === setting_zone);
if (!match) return;
const tr = body.insertRow();
tr.innerHTML = `
<td>${index+1}</td>
<td>${item.saddr}</td>
<td>${item.desc || '-'}</td>
<td>${item.type || '-'}</td>
<td>${match.val || '-'}</td>
<td>${ctrlTypeMap[item.ctrl_type] || item.ctrl_type}</td>
<td>${item.min || '-'}</td>
<td>${item.max || '-'}</td>
<td>${item.step || '-'}</td>
<td>${item.unit || '-'}</td>
<td>${match.default_val || '-'}</td>
`;
} else {
addNormalRow(type, item, index+1);
}
});
});
}
function addNormalRow(type, item, index) {
const body = tableBodies[type];
const tr = body.insertRow();
let html = `
<td>${index}</td>
<td>${item.saddr}</td>
<td>${item.desc || '-'}</td>
<td>${item.type || '-'}</td>
<td>${item.val || '-'}</td>
`;
if (type === 'yk') html += `<td>${ctrlTypeMap[item.ctrl_type] || item.ctrl_type}</td>`;
if (type === 'ao') {
html += `
<td>${ctrlTypeMap[item.ctrl_type] || item.ctrl_type}</td>
<td>${item.min || '-'}</td>
<td>${item.max || '-'}</td>
<td>${item.step || '-'}</td>
<td>${item.unit || '-'}</td>
<td>${item.default || '-'}</td>
`;
}
tr.innerHTML = html;
}
function sendSignal() {
const saddr = document.getElementById('saddr').value.trim();
const type = document.getElementById('signal_type').value;
const curd = document.getElementById('curd').value;
const setting_zone = document.getElementById('setting_zone').value.trim();
const data = document.getElementById('signal_data').value.trim();
if (!saddr) return addLog('saddr required', 'error');
if (!ws || ws.readyState !== 1) return addLog('not connected', 'error');
const msg = {
saddr,
signal_type: type,
curd,
setting_zone: setting_zone,
signal_data: data
};
ws.send(JSON.stringify(msg));
addLog(`sent: ${JSON.stringify(msg)}`, 'send');
parseData(JSON.parse(ws._lastData || '{}'));
}
function clearForm() {
document.getElementById('saddr').value = '';
document.getElementById('signal_data').value = '';
}
function clearAllTables() {
Object.values(tableBodies).forEach(body => body.innerHTML = '');
addLog('all tables cleared', 'info');
}
connectWS();
</script>
</body> </body>
</html> </html>

223
test/web_root/js/app.js Normal file
View File

@ -0,0 +1,223 @@
/**
* app.js 应用主控
* 路由调度页面生命周期管理WebSocket 数据分发
*/
var App = (function() {
var currentPage = null;
var currentHash = '';
var pages = {};
/* ---- 页面注册 ---- */
function registerPage(hash, page) {
pages[hash] = page;
}
/* ---- 侧边栏导航 ---- */
var navLinks = [
{ hash: '#dashboard', label: '🏠 功能导航' },
{ hash: '#out', label: '📡 注册 (out)' },
{ hash: '#in', label: '📊 链接注册 (in)' },
{ hash: '#yk', label: '⚡ 遥控 (yk)' },
{ hash: '#ao', label: '🔧 参数 (ao)' },
{ hash: '#param', label: '⚙ 定值 (param)' },
{ hash: '#monitor', label: '📋 数据监控' }
];
function buildNav() {
var nav = document.getElementById('nav-links');
if (!nav) return;
var html = '';
for (var i = 0; i < navLinks.length; i++) {
var link = navLinks[i];
html += '<a href="' + link.hash + '" data-hash="' + link.hash + '">' + link.label + '</a>';
}
nav.innerHTML = html;
}
function updateNavActive(hash) {
var links = document.querySelectorAll('#nav-links a');
for (var i = 0; i < links.length; i++) {
var linkHash = links[i].getAttribute('data-hash');
if (linkHash === hash) {
links[i].classList.add('active');
} else {
links[i].classList.remove('active');
}
}
}
function showSidebar(show) {
var sidebar = document.getElementById('nav-sidebar');
if (sidebar) {
sidebar.style.display = show ? 'flex' : 'none';
}
var main = document.getElementById('main-content');
if (main) {
main.style.marginLeft = show ? '220px' : '0';
}
}
/* ---- WS 状态更新 ---- */
function updateWsStatus(status) {
var dot = document.getElementById('nav-status-dot');
var text = document.getElementById('nav-status-text');
if (dot) {
dot.className = 'nav-status-dot ' + status;
}
if (text) {
var map = { connected: '已连接', disconnected: '已断开', connecting: '连接中...' };
text.textContent = map[status] || status;
}
}
/* ---- 路由调度 ---- */
function dispatch(hash) {
// 未登录 → 跳转到 login
if (hash !== '#login' && !localStorage.getItem('rtu_logged_in')) {
window.location.hash = '#login';
return;
}
// 已登录但访问 login → 跳转到 dashboard
if (hash === '#login' && localStorage.getItem('rtu_logged_in')) {
window.location.hash = '#dashboard';
return;
}
if (hash === currentHash && currentPage) return;
var container = document.getElementById('page-container');
if (!container) return;
// 离开旧页面
if (currentPage && currentPage.onLeave) {
currentPage.onLeave();
}
// 渲染新页面
var page = pages[hash];
if (!page) {
// 默认跳转到 login
window.location.hash = '#login';
return;
}
container.innerHTML = '';
page.render(container);
if (page.onEnter) {
page.onEnter();
}
// 页面进入后立即推送缓存数据(如有)
var stMap = { '#out': 'out', '#in': 'in', '#yk': 'yk', '#ao': 'ao', '#param': 'param' };
var st = stMap[hash];
if (st && page.onData && lastDataCache[st]) {
page.onData(lastDataCache[st]);
}
currentPage = page;
currentHash = hash;
// 更新导航
updateNavActive(hash);
// 登录页隐藏侧边栏
if (hash === '#login') {
showSidebar(false);
document.body.classList.add('login-page');
} else {
showSidebar(true);
document.body.classList.remove('login-page');
}
}
/* ---- 数据缓存:保留每种信号类型的最后数据,切换页面时立即恢复 ---- */
var lastDataCache = {};
/* ---- WS 下行数据分发 ---- */
function onWsMessage(data, raw) {
if (!data || typeof data !== 'object') return;
var signalTypes = ['out', 'in', 'yk', 'ao', 'param'];
for (var i = 0; i < signalTypes.length; i++) {
var st = signalTypes[i];
if (data[st]) {
lastDataCache[st] = data[st];
}
}
// 仅向当前页面推送对应类型的数据
var signalTypeMap = {
'#out': 'out',
'#in': 'in',
'#yk': 'yk',
'#ao': 'ao',
'#param': 'param'
};
var signalType = signalTypeMap[currentHash];
if (signalType && currentPage && currentPage.onData && data[signalType]) {
currentPage.onData(data[signalType]);
}
}
/* ---- 初始化 ---- */
function init() {
// 注册页面
registerPage('#login', LoginPage);
registerPage('#dashboard', DashboardPage);
registerPage('#out', OutPage);
registerPage('#in', InPage);
registerPage('#yk', YkPage);
registerPage('#ao', AoPage);
registerPage('#param', ParamPage);
registerPage('#monitor', MonitorPage);
// 构建导航
buildNav();
// 初始化 WebSocket
var wsUrl = (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws';
WsClient.connect(wsUrl);
// 监听 WS 状态
WsClient.onStatusChange(function(status) {
updateWsStatus(status);
});
// 监听 WS 下行消息
WsClient.onMessage(function(data, raw) {
onWsMessage(data, raw);
});
// 路由变化
window.addEventListener('hashchange', function() {
var hash = window.location.hash || '#login';
dispatch(hash);
});
// 登出按钮
var logoutBtn = document.getElementById('nav-logout');
if (logoutBtn) {
logoutBtn.onclick = function() {
localStorage.removeItem('rtu_logged_in');
window.location.hash = '#login';
};
}
// 首次加载
var initHash = window.location.hash || '#login';
dispatch(initHash);
}
return {
init: init,
dispatch: dispatch
};
})();
// 页面加载完成后启动
document.addEventListener('DOMContentLoaded', function() {
App.init();
});

View File

@ -0,0 +1,71 @@
/**
* monitor.js 数据监控器
* 环形缓冲记录所有 WebSocket 收发消息15 分钟自动清理超时记录
*/
var MonitorRing = (function() {
var records = [];
var MAX_RECORDS = 500;
var TTL_MS = 900000; // 15 分钟
var cleanTimer = null;
function push(dir, raw) {
var now = Date.now();
records.push({
time: now,
dir: dir, // '↑' 上行发送, '↓' 下行接收
data: raw
});
// 超出上限时截掉旧数据
while (records.length > MAX_RECORDS) {
records.shift();
}
}
function getAll() {
return records.slice();
}
function count() {
return records.length;
}
function cleanOld() {
var cutoff = Date.now() - TTL_MS;
var i = 0;
while (i < records.length && records[i].time < cutoff) {
i++;
}
if (i > 0) {
records.splice(0, i);
}
}
function clear() {
records = [];
}
// 每 60 秒自动清理
function startAutoClean() {
if (cleanTimer) return;
cleanTimer = setInterval(cleanOld, 60000);
}
function stopAutoClean() {
if (cleanTimer) {
clearInterval(cleanTimer);
cleanTimer = null;
}
}
// 启动自动清理
startAutoClean();
return {
push: push,
getAll: getAll,
count: count,
cleanOld: cleanOld,
clear: clear
};
})();

686
test/web_root/js/pages.js Normal file
View File

@ -0,0 +1,686 @@
/**
* pages.js 页面渲染器
*
* 每个页面对象实现
* render(container) 渲染 HTML 到容器
* onEnter() 页面激活时调用
* onLeave() 页面离开时调用
* onData(signalType, arr) 接收下行数据更新表格
*
* 术语映射
* out 注册 | in 链接注册 | yk 遥控 | ao 参数 | param 定值
*/
var CTRL_TYPE_MAP = { 0: '只读', 1: '直控', 2: '选控' };
/* ========================================================================
* 共用工具栏 HTML
* ======================================================================== */
function makeToolbar(signalType, showSet) {
var typeLabel = signalType === 'ao' ? '参数' : (signalType === 'param' ? '定值' : signalType);
return ''
+ '<div class="toolbar">'
+ '<div class="field">'
+ '<label>短地址 saddr</label>'
+ '<input type="text" id="tb-saddr" placeholder="例: iec.run_cnt">'
+ '</div>'
+ '<div class="field">'
+ '<label>操作</label>'
+ '<select id="tb-curd">'
+ '<option value="add">add 添加</option>'
+ '<option value="del">del 删除</option>'
+ '</select>'
+ '</div>'
+ '<button class="btn btn-primary" id="tb-exec">执行</button>'
+ '<button class="btn btn-outline" id="tb-clear">清空输入</button>'
+ '</div>';
}
function bindToolbar(signalType) {
var execBtn = document.getElementById('tb-exec');
var clearBtn = document.getElementById('tb-clear');
var saddrInput = document.getElementById('tb-saddr');
var curdSelect = document.getElementById('tb-curd');
if (!execBtn || !clearBtn) return;
execBtn.onclick = function() {
var saddr = saddrInput.value.trim();
var curd = curdSelect.value;
if (!saddr) {
alert('请输入短地址 saddr');
return;
}
if (!WsClient.send({ saddr: saddr, signal_type: signalType, curd: curd, setting_zone: '0', signal_data: '' })) {
alert('WebSocket 未连接');
}
};
clearBtn.onclick = function() {
saddrInput.value = '';
};
}
/* ========================================================================
* 表格渲染工具
* ======================================================================== */
function renderTable(containerId, headers, rowsHtml, emptyText) {
var html = '<div class="table-card"><div class="table-wrapper"><table class="signal-table">';
html += '<thead><tr>';
for (var i = 0; i < headers.length; i++) {
html += '<th>' + headers[i] + '</th>';
}
html += '</tr></thead><tbody id="' + containerId + '">';
if (rowsHtml) {
html += rowsHtml;
} else {
html += '<tr class="empty-row"><td colspan="' + headers.length + '">' + (emptyText || '暂无数据,请先添加信号') + '</td></tr>';
}
html += '</tbody></table></div></div>';
return html;
}
function fmtVal(v) {
if (v === null || v === undefined || v === '') return '-';
return String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ========================================================================
* 1. LoginPage 登录页
* ======================================================================== */
var LoginPage = {
render: function(container) {
container.innerHTML = ''
+ '<div class="login-card">'
+ '<h1>RTU 远程终端监控系统</h1>'
+ '<p class="login-subtitle">Remote Terminal Unit Web Monitor</p>'
+ '<div class="form-group">'
+ '<label>用户名</label>'
+ '<input type="text" id="login-user" placeholder="暂无需认证" disabled>'
+ '</div>'
+ '<div class="form-group">'
+ '<label>密码</label>'
+ '<input type="password" id="login-pass" placeholder="暂无需认证" disabled>'
+ '</div>'
+ '<button class="login-btn" id="login-btn">登 录</button>'
+ '<p class="login-hint">当前版本无需认证,点击登录即可进入系统</p>'
+ '</div>';
document.getElementById('login-btn').onclick = function() {
localStorage.setItem('rtu_logged_in', '1');
window.location.hash = '#dashboard';
};
},
onEnter: function() {
document.body.classList.add('login-page');
},
onLeave: function() {
document.body.classList.remove('login-page');
}
};
/* ========================================================================
* 2. DashboardPage 功能入口主页
* ======================================================================== */
var DashboardPage = {
render: function(container) {
var cards = [
{ cls: 'out', icon: '📡', title: '注册', desc: 'out — 状态量信号注册与修改', hash: '#out' },
{ cls: 'in', icon: '📊', title: '链接注册', desc: 'in — 测量量信号链接注册与监视', hash: '#in' },
{ cls: 'yk', icon: '⚡', title: '遥控', desc: 'yk — 遥控分合闸控制', hash: '#yk' },
{ cls: 'ao', icon: '🔧', title: '参数', desc: 'ao — 模拟输出参数设定', hash: '#ao' },
{ cls: 'param', icon: '⚙', title: '定值', desc: 'param — 多定值区参数管理', hash: '#param' },
{ cls: 'monitor', icon: '📋', title: '数据监控', desc: '实时查看 WebSocket 交互数据', hash: '#monitor' }
];
var html = '<div class="page-header"><h2>功能导航</h2><p>选择需要管理的信号类型进入对应功能页</p></div>';
html += '<div class="dashboard-grid">';
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
html += '<a class="dash-card ' + c.cls + '" href="' + c.hash + '">'
+ '<div class="card-icon">' + c.icon + '</div>'
+ '<div class="card-title">' + c.title + '</div>'
+ '<div class="card-desc">' + c.desc + '</div>'
+ '</a>';
}
html += '</div>';
container.innerHTML = html;
},
onEnter: function() {},
onLeave: function() {}
};
/* ========================================================================
* 3. OutPage 注册管理 (out)
* ======================================================================== */
var OutPage = (function() {
var data = [];
function renderTableHtml() {
if (data.length === 0) return '';
var rows = '';
for (var i = 0; i < data.length; i++) {
var item = data[i];
rows += '<tr>'
+ '<td>' + (i + 1) + '</td>'
+ '<td>' + fmtVal(item.saddr) + '</td>'
+ '<td>' + fmtVal(item.desc) + '</td>'
+ '<td>' + fmtVal(item.type) + '</td>'
+ '<td class="val-cell">' + fmtVal(item.val) + '</td>'
+ '<td><input class="val-input" id="mod-' + i + '" placeholder="修改值"></td>'
+ '<td><button class="btn btn-sm btn-info" data-idx="' + i + '">修改</button></td>'
+ '</tr>';
}
return rows;
}
function bindModifyButtons() {
var buttons = document.querySelectorAll('#out-tbody .btn-info');
for (var k = 0; k < buttons.length; k++) {
buttons[k].onclick = function() {
var idx = parseInt(this.getAttribute('data-idx'));
var input = document.getElementById('mod-' + idx);
var val = input.value.trim();
if (val === '') { alert('请输入修改值'); return; }
var item = data[idx];
WsClient.send({
curd: 'set', signal_type: 'out', saddr: item.saddr,
signal_data: val, setting_zone: '0'
});
};
}
}
return {
render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val', '修改值', '修改'];
container.innerHTML = ''
+ '<div class="page-header"><h2>注册管理</h2><p>out — 状态量信号,注册后支持添加、删除、修改值</p></div>'
+ makeToolbar('out')
+ renderTable('out-tbody', headers, renderTableHtml(), '暂无注册数据,请先添加信号');
bindToolbar('out');
bindModifyButtons();
},
onEnter: function() {},
onLeave: function() { data = []; },
onData: function(arr) {
data = arr || [];
var tbody = document.getElementById('out-tbody');
if (!tbody) return;
if (data.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="7">暂无注册数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml();
bindModifyButtons();
}
};
})();
/* ========================================================================
* 4. InPage 链接注册管理 (in)
* ======================================================================== */
var InPage = (function() {
var data = [];
function renderTableHtml() {
if (data.length === 0) return '';
var rows = '';
for (var i = 0; i < data.length; i++) {
var item = data[i];
rows += '<tr>'
+ '<td>' + (i + 1) + '</td>'
+ '<td>' + fmtVal(item.saddr) + '</td>'
+ '<td>' + fmtVal(item.desc) + '</td>'
+ '<td>' + fmtVal(item.type) + '</td>'
+ '<td class="val-cell">' + fmtVal(item.val) + '</td>'
+ '</tr>';
}
return rows;
}
return {
render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val'];
container.innerHTML = ''
+ '<div class="page-header"><h2>链接注册管理</h2><p>in — 测量量信号,链接注册后仅支持添加和删除,不支持修改</p></div>'
+ makeToolbar('in')
+ renderTable('in-tbody', headers, renderTableHtml(), '暂无链接注册数据,请先添加信号');
bindToolbar('in');
},
onEnter: function() {},
onLeave: function() { data = []; },
onData: function(arr) {
data = arr || [];
var tbody = document.getElementById('in-tbody');
if (!tbody) return;
if (data.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">暂无链接注册数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml();
}
};
})();
/* ========================================================================
* 5. YkPage 遥控管理 (yk)
* ======================================================================== */
var YkPage = (function() {
var data = [];
function renderTableHtml() {
if (data.length === 0) return '';
var rows = '';
for (var i = 0; i < data.length; i++) {
var item = data[i];
var ctrlLabel = CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type;
rows += '<tr>'
+ '<td>' + (i + 1) + '</td>'
+ '<td>' + fmtVal(item.saddr) + '</td>'
+ '<td>' + fmtVal(item.desc) + '</td>'
+ '<td>' + fmtVal(item.type) + '</td>'
+ '<td class="val-cell">' + fmtVal(item.val) + '</td>'
+ '<td>' + ctrlLabel + '</td>'
+ '<td><button class="btn btn-sm btn-success" data-idx="' + i + '" data-val="1">控合</button></td>'
+ '<td><button class="btn btn-sm btn-danger" data-idx="' + i + '" data-val="0">控分</button></td>'
+ '</tr>';
}
return rows;
}
function bindButtons() {
var buttons = document.querySelectorAll('#yk-tbody .btn-sm');
for (var k = 0; k < buttons.length; k++) {
buttons[k].onclick = function() {
var idx = parseInt(this.getAttribute('data-idx'));
var val = this.getAttribute('data-val');
var item = data[idx];
WsClient.send({
curd: 'set', signal_type: 'yk', saddr: item.saddr,
signal_data: val, setting_zone: '0'
});
};
}
}
return {
render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val', 'ctrl_type', '控合', '控分'];
container.innerHTML = ''
+ '<div class="page-header"><h2>遥控管理</h2><p>yk — 遥控分合闸控制,控合=1(闭合),控分=0(断开)</p></div>'
+ makeToolbar('yk')
+ renderTable('yk-tbody', headers, renderTableHtml(), '暂无遥控数据,请先添加信号');
bindToolbar('yk');
bindButtons();
},
onEnter: function() {},
onLeave: function() { data = []; },
onData: function(arr) {
data = arr || [];
var tbody = document.getElementById('yk-tbody');
if (!tbody) return;
if (data.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="8">暂无遥控数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml();
bindButtons();
}
};
})();
/* ========================================================================
* 6. AoPage 参数管理 (ao, 单值修改)
* ======================================================================== */
var AoPage = (function() {
var data = [];
function renderTableHtml() {
if (data.length === 0) return '';
var rows = '';
for (var i = 0; i < data.length; i++) {
var item = data[i];
var ctrlLabel = CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type;
rows += '<tr>'
+ '<td>' + (i + 1) + '</td>'
+ '<td>' + fmtVal(item.saddr) + '</td>'
+ '<td>' + fmtVal(item.desc) + '</td>'
+ '<td>' + fmtVal(item.type) + '</td>'
+ '<td class="val-cell">' + fmtVal(item.val) + '</td>'
+ '<td>' + ctrlLabel + '</td>'
+ '<td>' + fmtVal(item.min) + '</td>'
+ '<td>' + fmtVal(item.max) + '</td>'
+ '<td>' + fmtVal(item.step) + '</td>'
+ '<td>' + fmtVal(item.unit) + '</td>'
+ '<td><input class="val-input" id="ao-mod-' + i + '" placeholder="修改值"></td>'
+ '<td><button class="btn btn-sm btn-info" data-idx="' + i + '">修改</button></td>'
+ '</tr>';
}
return rows;
}
function bindModifyButtons() {
var buttons = document.querySelectorAll('#ao-tbody .btn-info');
for (var k = 0; k < buttons.length; k++) {
buttons[k].onclick = function() {
var idx = parseInt(this.getAttribute('data-idx'));
var input = document.getElementById('ao-mod-' + idx);
var val = input.value.trim();
if (val === '') { alert('请输入修改值'); return; }
var item = data[idx];
WsClient.send({
curd: 'set', signal_type: 'ao', saddr: item.saddr,
signal_data: val, setting_zone: '0'
});
};
}
}
return {
render: function(container) {
var headers = ['#', 'saddr', 'desc', '数据类型', 'val', 'ctrl_type', 'min', 'max', 'step', 'unit', '修改值', '修改'];
container.innerHTML = ''
+ '<div class="page-header"><h2>参数管理</h2><p>ao — 模拟输出参数设定,支持单值修改</p></div>'
+ makeToolbar('ao')
+ renderTable('ao-tbody', headers, renderTableHtml(), '暂无参数数据,请先添加信号');
bindToolbar('ao');
bindModifyButtons();
},
onEnter: function() {},
onLeave: function() { data = []; },
onData: function(arr) {
data = arr || [];
var tbody = document.getElementById('ao-tbody');
if (!tbody) return;
if (data.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="12">暂无参数数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml();
bindModifyButtons();
}
};
})();
/* ========================================================================
* 7. ParamPage 定值管理 (param, 多定值区展开)
* ======================================================================== */
var ParamPage = (function() {
var data = [];
var expandedRows = {}; // 记录展开状态
function renderTableHtml() {
if (data.length === 0) return '';
var rows = '';
for (var i = 0; i < data.length; i++) {
var item = data[i];
var ctrlLabel = CTRL_TYPE_MAP[item.ctrl_type] || item.ctrl_type;
var zones = item.setting_zone_list || [];
var zoneCount = zones.length;
var isOpen = expandedRows[i] === true;
var icon = isOpen ? '▼' : '▶';
// 主行(可点击展开)
rows += '<tr class="expand-row" data-pidx="' + i + '">'
+ '<td>' + (i + 1) + '</td>'
+ '<td>' + fmtVal(item.saddr) + '</td>'
+ '<td>' + fmtVal(item.desc) + '</td>'
+ '<td>' + fmtVal(item.type) + '</td>'
+ '<td>' + ctrlLabel + '</td>'
+ '<td>' + zoneCount + '</td>'
+ '<td><span class="expand-icon' + (isOpen ? ' open' : '') + '">' + icon + '</span> 展开</td>'
+ '</tr>';
// 展开的定值区子表
if (isOpen) {
rows += '<tr class="zone-sub-row" id="zone-sub-' + i + '"><td colspan="7">'
+ '<table class="zone-sub-table">'
+ '<thead><tr>'
+ '<th>定值区 ID</th><th>val</th><th>default_val</th>'
+ '<th>min</th><th>max</th><th>step</th><th>unit</th>'
+ '<th>修改值</th><th>修改</th>'
+ '</tr></thead><tbody>';
for (var j = 0; j < zones.length; j++) {
var zone = zones[j];
rows += '<tr>'
+ '<td>' + fmtVal(zone.id) + '</td>'
+ '<td>' + fmtVal(zone.val) + '</td>'
+ '<td>' + fmtVal(zone.default_val) + '</td>'
+ '<td>' + fmtVal(item.min) + '</td>'
+ '<td>' + fmtVal(item.max) + '</td>'
+ '<td>' + fmtVal(item.step) + '</td>'
+ '<td>' + fmtVal(item.unit) + '</td>'
+ '<td><input class="val-input" id="pm-' + i + '-' + j + '" placeholder="修改值"></td>'
+ '<td><button class="btn btn-sm btn-info" data-pidx="' + i + '" data-zidx="' + j + '">修改</button></td>'
+ '</tr>';
}
rows += '</tbody></table></td></tr>';
}
}
return rows;
}
function bindEvents() {
// 展开/收起
var expandRows = document.querySelectorAll('#param-tbody .expand-row');
for (var k = 0; k < expandRows.length; k++) {
expandRows[k].onclick = function() {
var idx = parseInt(this.getAttribute('data-pidx'));
expandedRows[idx] = !expandedRows[idx];
refreshTable();
};
}
// 修改按钮
var modButtons = document.querySelectorAll('#param-tbody .btn-info');
for (var m = 0; m < modButtons.length; m++) {
modButtons[m].onclick = function(e) {
e.stopPropagation();
var pidx = parseInt(this.getAttribute('data-pidx'));
var zidx = parseInt(this.getAttribute('data-zidx'));
var input = document.getElementById('pm-' + pidx + '-' + zidx);
var val = input.value.trim();
if (val === '') { alert('请输入修改值'); return; }
var item = data[pidx];
var zoneId = item.setting_zone_list[zidx].id;
WsClient.send({
curd: 'set', signal_type: 'param', saddr: item.saddr,
signal_data: val, setting_zone: zoneId
});
};
}
}
function refreshTable() {
var tbody = document.getElementById('param-tbody');
if (!tbody) return;
tbody.innerHTML = renderTableHtml();
bindEvents();
}
return {
render: function(container) {
expandedRows = {};
var headers = ['#', 'saddr', 'desc', '数据类型', 'ctrl_type', '定值区数', '展开'];
container.innerHTML = ''
+ '<div class="page-header"><h2>定值管理</h2><p>param — 多定值区参数管理,点击行展开查看各定值区详情并可修改</p></div>'
+ makeToolbar('param')
+ renderTable('param-tbody', headers, renderTableHtml(), '暂无定值数据,请先添加信号');
bindToolbar('param');
bindEvents();
},
onEnter: function() {},
onLeave: function() { data = []; expandedRows = {}; },
onData: function(arr) {
data = arr || [];
expandedRows = {}; // 新数据到达时重置展开状态
var tbody = document.getElementById('param-tbody');
if (!tbody) return;
if (data.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="7">暂无定值数据,请先添加信号</td></tr>';
return;
}
tbody.innerHTML = renderTableHtml();
bindEvents();
}
};
})();
/* ========================================================================
* 8. MonitorPage 数据交互监控可展开列表15 分钟留存
* ======================================================================== */
var MonitorPage = (function() {
var refreshTimer = null;
var expandedSet = {}; // 记录展开状态 {index: true}
function formatTime(ts) {
var d = new Date(ts);
var h = String(d.getHours()).padStart(2, '0');
var m = String(d.getMinutes()).padStart(2, '0');
var s = String(d.getSeconds()).padStart(2, '0');
var ms = String(d.getMilliseconds()).padStart(3, '0');
return h + ':' + m + ':' + s + '.' + ms;
}
function buildSummary(dir, raw) {
try {
var obj = JSON.parse(raw);
if (dir === '↑') {
// 上行:提取 curd, signal_type, saddr
var parts = [];
if (obj.curd) parts.push(obj.curd);
if (obj.signal_type) parts.push(obj.signal_type);
if (obj.saddr) parts.push(obj.saddr);
return parts.length > 0 ? parts.join(' ') : raw.substring(0, 60);
} else {
// 下行:统计各信号类型数量
var summaryParts = [];
var types = ['out', 'in', 'yk', 'ao', 'param'];
for (var t = 0; t < types.length; t++) {
var arr = obj[types[t]];
if (Array.isArray(arr) && arr.length > 0) {
summaryParts.push(types[t] + '×' + arr.length);
}
}
return summaryParts.length > 0 ? summaryParts.join(', ') : raw.substring(0, 60);
}
} catch (e) {
return raw.substring(0, 60);
}
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderLog() {
var el = document.getElementById('monitor-log');
if (!el) return;
var records = MonitorRing.getAll();
if (records.length === 0) {
el.innerHTML = '<div class="monitor-empty">暂无数据交互,请先操作信号页面...</div>';
document.getElementById('monitor-count').textContent = '0';
return;
}
// 保存滚动状态:用户是否在底部
var prevScrollTop = el.scrollTop;
var prevClientHeight = el.clientHeight;
var prevScrollHeight = el.scrollHeight;
var wasAtBottom = (prevScrollTop + prevClientHeight >= prevScrollHeight - 10);
var html = '';
for (var i = 0; i < records.length; i++) {
var r = records[i];
var isOpen = expandedSet[i] === true;
var summary = buildSummary(r.dir, r.data);
var dirClass = r.dir === '↑' ? 'mon-up' : 'mon-down';
var dirLabel = r.dir === '↑' ? '↑ 发送' : '↓ 接收';
var source = r.dir === '↑' ? '客户端' : 'RTU Server';
var target = r.dir === '↑' ? 'RTU Server' : '客户端';
var arrow = r.dir === '↑' ? '→' : '←';
html += '<div class="mon-item' + (isOpen ? ' open' : '') + '" data-midx="' + i + '">';
html += '<div class="mon-item-head">';
html += '<span class="mon-time">[' + formatTime(r.time) + ']</span>';
html += '<span class="mon-dir ' + dirClass + '">' + dirLabel + '</span>';
html += '<span class="mon-addr">' + source + ' ' + arrow + ' ' + target + '</span>';
html += '<span class="mon-summary">' + escapeHtml(summary) + '</span>';
html += '<span class="mon-expand-icon">' + (isOpen ? '▼' : '▶') + '</span>';
html += '</div>';
if (isOpen) {
html += '<div class="mon-item-body"><pre>' + escapeHtml(r.data) + '</pre></div>';
}
html += '</div>';
}
el.innerHTML = html;
// 绑定点击展开/收起
var heads = el.querySelectorAll('.mon-item-head');
for (var k = 0; k < heads.length; k++) {
heads[k].onclick = function() {
var item = this.parentElement;
var idx = parseInt(item.getAttribute('data-midx'));
expandedSet[idx] = !expandedSet[idx];
renderLog();
};
}
// 恢复滚动位置:在底部才跟随,否则保持原位
if(wasAtBottom)
{
el.scrollTop = el.scrollHeight;
}
else if(prevScrollHeight > 0)
{
var ratio = prevScrollTop / prevScrollHeight;
el.scrollTop = Math.round(ratio * el.scrollHeight);
}
document.getElementById('monitor-count').textContent = records.length;
}
return {
render: function(container) {
expandedSet = {};
container.innerHTML = ''
+ '<div class="page-header"><h2>数据监控</h2><p>实时查看 WebSocket 上下行 JSON 数据15 分钟自动清理历史,点击条目展开详情</p></div>'
+ '<div class="monitor-stats">'
+ '<span class="stat-label">状态:</span>'
+ '<span class="stat-value" id="monitor-ws-status">' + WsClient.getStatus() + '</span>'
+ '<span class="stat-label" style="margin-left:16px">已记录:</span>'
+ '<span class="stat-value" id="monitor-count">0</span>'
+ '<span class="stat-label">条</span>'
+ '<button class="btn btn-sm btn-outline" id="monitor-clear" style="margin-left:auto">清空记录</button>'
+ '</div>'
+ '<div class="monitor-log" id="monitor-log"></div>';
document.getElementById('monitor-clear').onclick = function() {
MonitorRing.clear();
expandedSet = {};
renderLog();
};
renderLog();
},
onEnter: function() {
refreshTimer = setInterval(renderLog, 500);
},
onLeave: function() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
expandedSet = {};
}
};
})();

View File

@ -0,0 +1,156 @@
/**
* ws-client.js WebSocket 客户端单例
* 全站共享一个 WS 连接提供自动重连消息分发状态通知
*/
var WsClient = (function() {
var ws = null;
var url = '';
var msgCallbacks = [];
var statusCallbacks = [];
var reconnectTimer = null;
var reconnectDelay = 3000;
var status = 'disconnected'; // 'connected' | 'disconnected' | 'connecting'
function setStatus(s) {
if (status !== s) {
status = s;
for (var i = 0; i < statusCallbacks.length; i++) {
statusCallbacks[i](s);
}
}
}
function connect(wsUrl) {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
url = wsUrl;
setStatus('connecting');
try {
ws = new WebSocket(url);
} catch (e) {
setStatus('disconnected');
scheduleReconnect();
return;
}
ws.onopen = function() {
setStatus('connected');
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
ws.onclose = function() {
setStatus('disconnected');
ws = null;
scheduleReconnect();
};
ws.onerror = function() {
// onclose will fire after onerror
};
ws.onmessage = function(e) {
var raw = e.data;
// 写入监控日志
if (typeof MonitorRing !== 'undefined') {
MonitorRing.push('↓', raw);
}
// 尝试解析 JSON
var data = null;
try {
data = JSON.parse(raw);
} catch (ex) {
// 非 JSON 消息也分发原始数据
}
// 分发给所有注册的回调
for (var i = 0; i < msgCallbacks.length; i++) {
try {
msgCallbacks[i](data || raw, raw);
} catch (ex) {
console.error('WsClient msg callback error:', ex);
}
}
};
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(function() {
reconnectTimer = null;
if (status === 'disconnected') {
connect(url);
}
}, reconnectDelay);
}
function send(data) {
var raw = '';
if (typeof data === 'object') {
raw = JSON.stringify(data);
} else {
raw = String(data);
}
// 写入监控日志
if (typeof MonitorRing !== 'undefined') {
MonitorRing.push('↑', raw);
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.error('WsClient: not connected, cannot send');
return false;
}
try {
ws.send(raw);
return true;
} catch (e) {
console.error('WsClient: send failed', e);
return false;
}
}
function onMessage(cb) {
msgCallbacks.push(cb);
}
function onStatusChange(cb) {
statusCallbacks.push(cb);
// 立即通知当前状态
cb(status);
}
function getStatus() {
return status;
}
function disconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null; // 阻止自动重连
ws.close();
ws = null;
}
setStatus('disconnected');
}
return {
connect: connect,
send: send,
onMessage: onMessage,
onStatusChange: onStatusChange,
getStatus: getStatus,
disconnect: disconnect
};
})();