<修改> 1、调整webserver,更新mongoose库源文件;2、重新生成前端工程;3、利用mongoose中的功能打包前端工程进项目工程中,以c文件格式存在,最终编译成可执行文件一起打包,后续升级改动前端时,无需升级前端工程
This commit is contained in:
parent
a3ae78e5e8
commit
46f9f4fe76
|
|
@ -17,6 +17,7 @@ if [ "$1" = "arm" ]; then
|
|||
echo " ARM 交叉编译 开始"
|
||||
echo "====================================="
|
||||
make CROSS=arm clean
|
||||
bash "$(dirname "$0")/pack_web.sh"
|
||||
make CROSS=arm
|
||||
else
|
||||
echo "====================================="
|
||||
|
|
@ -24,6 +25,7 @@ else
|
|||
echo "====================================="
|
||||
rm -f "$(dirname "$0")/../test/RTU"
|
||||
make clean
|
||||
bash "$(dirname "$0")/pack_web.sh"
|
||||
make
|
||||
fi
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)"
|
||||
|
|
@ -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
|
|
@ -4,6 +4,8 @@
|
|||
#include "myFunc.h"
|
||||
#include "mongoose.h"
|
||||
|
||||
#define USE_PACKED_FS
|
||||
|
||||
|
||||
static const char *s_listen_on = "http://0.0.0.0:8000";
|
||||
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
|
||||
{
|
||||
#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()};
|
||||
#endif
|
||||
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;
|
||||
|
||||
mg_log_set(MG_LL_ERROR);
|
||||
mg_mgr_init(&mgr);
|
||||
|
||||
mg_http_listen(&mgr, s_listen_on, web_server_task, NULL);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -1,294 +1,38 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>XTU WEB DEBUG - Signal Monitor</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }
|
||||
.container { max-width: 1400px; margin: 0 auto; }
|
||||
h1 { color: #333; text-align: center; }
|
||||
.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>
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>RTU 远程终端监控系统</title>
|
||||
<link rel="stylesheet" href="css/style.css?v=2">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>XTU WEB DEBUG - Signal Monitor</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>saddr *</label>
|
||||
<input id="saddr" class="saddr-input" placeholder="iec.run_cnt">
|
||||
</div>
|
||||
<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>
|
||||
<!-- 侧边导航栏(登录页隐藏) -->
|
||||
<aside id="nav-sidebar">
|
||||
<div class="nav-header">RTU 监控系统</div>
|
||||
<div class="nav-status">
|
||||
<span class="nav-status-dot disconnected" id="nav-status-dot"></span>
|
||||
<span id="nav-status-text">已断开</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
WebSocket Status: <span id="status" class="status-disconnected">Disconnected</span>
|
||||
<button onclick="connectWS()" class="secondary">Connect</button>
|
||||
<nav id="nav-links"></nav>
|
||||
<div class="nav-footer">
|
||||
<button id="nav-logout">退出登录</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<div class="table-title">OUT 信号</div>
|
||||
<div class="table-wrapper">
|
||||
<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>
|
||||
<!-- 主内容区 -->
|
||||
<main id="main-content">
|
||||
<div id="page-container"></div>
|
||||
</main>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-title">IN 信号</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="signalTable">
|
||||
<thead><tr><th>#</th><th>saddr</th><th>desc</th><th>数据类型</th><th>val</th></tr></thead>
|
||||
<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>
|
||||
<!-- JS 依赖加载(顺序依赖) -->
|
||||
<script src="js/monitor.js"></script>
|
||||
<script src="js/ws-client.js"></script>
|
||||
<script src="js/pages.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 = {};
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in New Issue