Pārlūkot izejas kodu

双机16路单屏: 主从角色可切换 + 画面/报警聚合

新增能力:
- 角色: standalone/master/slave, 网页配置(重启生效), 两台可各自接屏切换主从
- 通道全局编号: local_base(0/8), 与角色解耦, 编号稳定 CH0-15
- 主节点: 4x4 宫格(本地8+对端8), 每路独立HTTP客户端拉取对端画面(8fps)
- 状态聚合: /api/status 合并对端通道(在线/帧龄/分辨率)
- 转发: /stream /mjpeg 对端通道由主节点提供(拉取缓存帧)
- 报警聚合: 围栏+姿态报警/计数合并, 删除/清空分发到对应节点(src=local/peer)
- 截图代理: /api/peer/image/:file 直接返回对端截图字节
- 防误配: 对端不能指向本机同端口(避免状态查询递归)
- 前端: 系统设置新增"双机模式"配置; 16路4x4布局; 报警截图按来源取图

已验证: 本机模拟从节点(8081)+主节点(8080), 对端画面在线/8fps, 报警合并/筛选/删除/截图代理均正常
dev 3 stundas atpakaļ
vecāks
revīzija
b572b64257
9 mainītis faili ar 706 papildinājumiem un 66 dzēšanām
  1. 1 0
      CMakeLists.txt
  2. 19 7
      config/config.json
  3. 404 42
      src/main.cpp
  4. 106 0
      src/remote_source.cpp
  5. 39 0
      src/remote_source.h
  6. 43 0
      src/web_server.cpp
  7. 4 0
      src/web_server.h
  8. 84 11
      web/index.html
  9. 6 6
      web/mobile.html

+ 1 - 0
CMakeLists.txt

@@ -34,6 +34,7 @@ add_executable(zone_alarm
     src/bucket_manager.cpp
     src/idle_detector.cpp
     src/pose_detector.cpp
+    src/remote_source.cpp
     src/display_output.cpp
 )
 

+ 19 - 7
config/config.json

@@ -8,7 +8,7 @@
         "train"
     ],
     "alarm_retention_days": 30,
-    "app_name": "\u94a2\u5305\u5de5\u4f4d\u89c6\u9891\u76d1\u6d4b\u7cfb\u7edf",
+    "app_name": "钢包工位视频监测系统",
     "auto_reboot": {
         "cycle": 1,
         "enabled": false,
@@ -18,7 +18,7 @@
         {
             "code_x": "00000001",
             "code_y": "",
-            "description": "1\u53f7\u5de5\u4f4d",
+            "description": "1号工位",
             "id": 1,
             "left_camera": {
                 "topic": "",
@@ -32,7 +32,7 @@
         {
             "code_x": "00000002",
             "code_y": "",
-            "description": "2\u53f7\u5de5\u4f4d",
+            "description": "2号工位",
             "id": 2,
             "left_camera": {
                 "topic": "",
@@ -98,9 +98,9 @@
     "relay": {
         "alarm_max_sec": 30,
         "aliases": {
-            "x1": "\u5927\u95e8\u96f7\u8fbe",
-            "y01": "\u8b66\u793a\u706f",
-            "y02": "\u58f0\u5149\u62a5\u8b66\u5668"
+            "x1": "大门雷达",
+            "y01": "警示灯",
+            "y02": "声光报警器"
         },
         "device_addr": 1,
         "di_bindings": {
@@ -203,5 +203,17 @@
     },
     "web_port": 8080,
     "watchdog_sec": 120,
-    "display_fps": 12
+    "display_fps": 12,
+    "node_role": "standalone",
+    "local_base": 0,
+    "peer": {
+        "enabled": false,
+        "host": "",
+        "port": 8081,
+        "username": "admin",
+        "password": "admin",
+        "local_base": 8,
+        "count": 2,
+        "fps": 8
+    }
 }

+ 404 - 42
src/main.cpp

@@ -38,6 +38,7 @@
 #include "idle_detector.h"
 #include "pose_detector.h"
 #include "display_output.h"
+#include "remote_source.h"
 
 using namespace std;
 using json = nlohmann::json;
@@ -115,6 +116,15 @@ struct ChannelState {
     mutex mtx;
 };
 
+// 对端通道的最新画面缓存(主节点拉取对端时使用)
+struct PeerChan {
+    mutex m;
+    cv::Mat frame;
+    std::atomic<uint64_t> seq{0};
+    std::atomic<int64_t> last_ms{0};
+};
+
+
 static json loadJson(const string& path) {
     ifstream ifs(path);
     if (!ifs.is_open()) return json::object();
@@ -400,6 +410,48 @@ int main(int argc, char* argv[]) {
     // 显示帧生成帧率(所有输出端HDMI/MJPEG/手机最高约10-12fps, 无需每输入帧都生成)
     int display_fps = clamp(cfg.value("display_fps", 12), 1, 30);
     int display_interval_ms = 1000 / display_fps;
+
+    // ========== 双机主从(16路单屏) ==========
+    // 角色: master=显示16格+聚合, slave=仅采集推理供主节点拉取, standalone=单机
+    string node_role = cfg.value("node_role", "standalone");
+    // 本机通道全局编号起点(节点1=0, 节点2=8; 与角色无关, 保证编号稳定)
+    int local_base = clamp(cfg.value("local_base", 0), 0, 8);
+    // 对端节点(主节点拉取对端画面/报警)
+    bool peer_enabled = false;
+    string peer_host;
+    int peer_port = 8080;
+    string peer_user = "admin", peer_pass = "admin";
+    int peer_base = (local_base == 0) ? 8 : 0;
+    int peer_count = 8;
+    int remote_fps = 8;
+    if (cfg.contains("peer") && cfg["peer"].is_object()) {
+        auto& p = cfg["peer"];
+        peer_enabled = p.value("enabled", false);
+        peer_host = p.value("host", "");
+        peer_port = p.value("port", 8080);
+        peer_user = p.value("username", "admin");
+        peer_pass = p.value("password", "admin");
+        peer_base = p.value("local_base", peer_base);
+        peer_count = clamp(p.value("count", 8), 1, 8);
+        remote_fps = clamp(p.value("fps", 8), 1, 15);
+    }
+    bool is_master = (node_role == "master");
+    bool peer_active = is_master && peer_enabled && !peer_host.empty();
+    // 防止误配成本机自身(会导致状态查询递归死锁)
+    if (peer_active && (peer_host == "127.0.0.1" || peer_host == "localhost") &&
+        peer_port == web_port) {
+        cerr << "[Node] 对端地址不能是本机自身, 已禁用对端拉取" << endl;
+        peer_active = false;
+    }
+    // slave 强制关闭本机HDMI显示(省CPU, 供主节点拉取)
+    if (node_role == "slave") {
+        grid_output = false;
+        steel_output = false;
+        enable_bucket = false;
+    }
+    cout << "[Node] 角色=" << node_role << " 本机通道起点=" << local_base
+         << " 对端=" << (peer_active ? (peer_host + ":" + to_string(peer_port)) : "无")
+         << (peer_active ? (" 对端起点=" + to_string(peer_base)) : "") << endl;
     cout << "[Display] 网格输出: " << (grid_output ? "启用" : "禁用")
          << " 钢包位输出: " << (steel_output ? "启用" : "禁用")
          << " 工位功能: " << (enable_bucket ? "启用" : "禁用")
@@ -648,7 +700,8 @@ int main(int argc, char* argv[]) {
     }
 
     // ========== 通道容器 ==========
-    static const int kMaxChannels = 8;
+    static const int kMaxChannels = 16;   // 全局通道上限(本地8+对端8)
+    static const int kMaxLocalChannels = 8;
     mutex channels_mtx;
     vector<shared_ptr<ChannelState>> channels;
 
@@ -661,6 +714,9 @@ int main(int argc, char* argv[]) {
     WebServer web;
     DisplayOutput displayGrid;    // 普通通道网格(自动布局)
     DisplayOutput displayBucket;  // 钢包位(当前工位左右摄像头)
+    shared_ptr<RemoteClient> remote;          // 对端访问客户端(主节点)
+    vector<shared_ptr<PeerChan>> peer_chans;  // 对端通道缓存
+    vector<shared_ptr<RemoteClient>> peer_clients; // 每路独立HTTP客户端
     web.setPort(web_port);
 
     // 登录凭据(默认admin/admin), 持久化于config.json的web_auth
@@ -730,19 +786,20 @@ int main(int argc, char* argv[]) {
         return ch;
     };
     auto registerChannelProviders = [&](shared_ptr<ChannelState> ch) {
-        web.setFrameProvider(ch->id, [ch]() -> cv::Mat {
+        int gid = local_base + ch->id;
+        web.setFrameProvider(gid, [ch]() -> cv::Mat {
             lock_guard<mutex> lk(ch->mtx);
             if (!ch->display_frame.empty())
                 return ch->display_frame.clone();
             return cv::Mat(360, 640, CV_8UC3, cv::Scalar(0, 0, 0));
         });
-        web.setFrameSeqProvider(ch->id, [ch]() -> uint64_t {
+        web.setFrameSeqProvider(gid, [ch]() -> uint64_t {
             return ch->disp_seq.load(std::memory_order_relaxed);
         });
     };
 
     json rtsp_list = cfg.value("rtsp", json::array());
-    for (int i = 0; i < (int)rtsp_list.size() && i < kMaxChannels; i++) {
+    for (int i = 0; i < (int)rtsp_list.size() && i < kMaxLocalChannels; i++) {
         auto ch = makeChannel(rtsp_list[i].value("id", i),
                               rtsp_list[i].value("name", ""),
                               rtsp_list[i].value("url", ""));
@@ -751,8 +808,8 @@ int main(int argc, char* argv[]) {
         lock_guard<mutex> lk(channels_mtx);
         channels.push_back(std::move(ch));
     }
-    cout << "[Main] 视频通道: " << channels.size() << "/"
-         << kMaxChannels << " 路" << endl;
+    cout << "[Main] 本地视频通道: " << channels.size() << "/"
+         << kMaxLocalChannels << " 路" << endl;
 
     // 钢包位专用通道(占位创建, 工位激活时替换为工位左右摄像头)
     if (enable_bucket) {
@@ -827,11 +884,13 @@ int main(int argc, char* argv[]) {
         s["fall_relays"] = fall_relays;
         s["grid_output"] = grid_output;
         s["steel_output"] = steel_output;
+        s["node_role"] = node_role;
+        s["local_base"] = local_base;
         json arr = json::array();
         {
             lock_guard<mutex> lk(channels_mtx);
             for (auto& ch : channels) {
-                json c; c["id"] = ch->id;
+                json c; c["id"] = local_base + ch->id;
                 c["name"] = ch->capture->name();
                 c["url"] = ch->capture->url();
                 c["connected"] = ch->capture->isRunning();
@@ -843,9 +902,45 @@ int main(int argc, char* argv[]) {
                 c["pose_running"] = ch->pose_running;
                 c["fall_count"] = ch->pose_fall_count;
                 c["falls"] = (int)ch->fall_poses.size();
+                c["remote"] = false;
                 arr.push_back(c);
             }
         }
+        // 合入对端通道
+        if (peer_active) {
+            json pj;
+            bool ok = remote && remote->getJson("/api/status", pj);
+            for (int i = 0; i < peer_count; i++) {
+                json c;
+                c["id"] = peer_base + i;
+                c["name"] = "远端" + to_string(i + 1);
+                c["url"] = "";
+                c["remote"] = true;
+                auto pc = peer_chans[i];
+                int64_t lm = pc ? pc->last_ms.load(std::memory_order_relaxed) : 0;
+                int age = lm ? (int)(nowMs() - lm) : -1;
+                c["online"] = (age >= 0 && age < 5000);
+                c["connected"] = c["online"];
+                c["frame_age_ms"] = age;
+                c["width"] = 640; c["height"] = 360;
+                if (ok && pj.contains("channels") && pj["channels"].is_array()) {
+                    for (auto& rc2 : pj["channels"]) {
+                        if (rc2.value("id", -1) == peer_base + i) {
+                            c["name"] = rc2.value("name", c["name"]);
+                            c["width"] = rc2.value("width", 640);
+                            c["height"] = rc2.value("height", 360);
+                            c["pose_running"] = rc2.value("pose_running", false);
+                            break;
+                        }
+                    }
+                }
+                arr.push_back(c);
+            }
+            // 按全局id排序, 保证显示顺序稳定
+            std::sort(arr.begin(), arr.end(), [](const json& a, const json& b) {
+                return a.value("id", 0) < b.value("id", 0);
+            });
+        }
         s["channels"] = arr;
         return s.dump();
     });
@@ -1335,7 +1430,11 @@ int main(int argc, char* argv[]) {
             json j = json::parse(body);
             string name = j.value("name", "");
             string url = j.value("url", "");
-            int id = j.value("id", -1);
+            int gid = j.value("id", -1);
+            // 前端用全局id, 转成本地id
+            int id = (gid < 0) ? -1 : (gid - local_base);
+            if (gid >= 0 && (id < 0 || id >= kMaxLocalChannels))
+                return R"({"ok":false,"error":"非本机通道"})";
             if (url.rfind("rtsp://", 0) != 0)
                 return R"({"ok":false,"error":"URL必须以rtsp://开头"})";
             if (name.empty()) name = "通道";
@@ -1350,8 +1449,8 @@ int main(int argc, char* argv[]) {
                     if (!old)
                         return R"({"ok":false,"error":"通道不存在"})";
                 } else {
-                    if ((int)channels.size() >= kMaxChannels)
-                        return R"({"ok":false,"error":"已达最大通道数8"})";
+                    if ((int)channels.size() >= kMaxLocalChannels)
+                        return R"({"ok":false,"error":"已达最大本地通道数8"})";
                     int max_id = -1;
                     for (auto& ch : channels) max_id = std::max(max_id, ch->id);
                     nu->id = id = max_id + 1;
@@ -1369,7 +1468,7 @@ int main(int argc, char* argv[]) {
             persistRtspList();
             cout << "[Channel] " << (old ? "修改" : "添加")
                  << " CH" << id << ": " << url << endl;
-            json r; r["ok"] = true; r["id"] = id;
+            json r; r["ok"] = true; r["id"] = local_base + id;
             return r.dump();
         } catch (exception& e) {
             return string(R"({"ok":false,"error":")") + e.what() + R"("})";
@@ -1379,7 +1478,10 @@ int main(int argc, char* argv[]) {
     web.setPostHandler("/api/channels/delete", [&](const string& body) -> string {
         try {
             json j = json::parse(body);
-            int id = j.value("id", -1);
+            int gid = j.value("id", -1);
+            int id = (gid < 0) ? -1 : (gid - local_base);
+            if (gid >= 0 && (id < 0 || id >= kMaxLocalChannels))
+                return R"({"ok":false,"error":"非本机通道"})";
             shared_ptr<ChannelState> victim;
             {
                 lock_guard<mutex> lk(channels_mtx);
@@ -1393,7 +1495,7 @@ int main(int argc, char* argv[]) {
             }
             if (!victim)
                 return R"({"ok":false,"error":"通道不存在"})";
-            web.removeFrameProvider(id);
+            web.removeFrameProvider(local_base + id);
             stopChannelInfer(victim);   // 先停推理线程(避免访问已释放的ChannelState)
             victim->capture->stop();    // 锁外停止并回收解码线程
             persistRtspList();
@@ -1551,6 +1653,92 @@ int main(int argc, char* argv[]) {
         } catch (...) { return R"({"ok":false})"; }
     });
 
+    // ========== 双机角色配置(网页配置, 重启生效) ==========
+    web.setGetHandler("/api/node", [&]() -> string {
+        json r;
+        r["role"] = node_role;
+        r["local_base"] = local_base;
+        r["peer"] = {
+            {"enabled", peer_enabled},
+            {"host", peer_host},
+            {"port", peer_port},
+            {"username", peer_user},
+            {"password", peer_pass},
+            {"local_base", peer_base},
+            {"count", peer_count},
+            {"fps", remote_fps}
+        };
+        return r.dump();
+    });
+
+    web.setPostHandler("/api/node", [&](const string& body) -> string {
+        try {
+            json j = json::parse(body);
+            json full = loadJson(config_path);
+            if (j.contains("role") && j["role"].is_string()) {
+                string r = j["role"].get<string>();
+                if (r == "master" || r == "slave" || r == "standalone")
+                    full["node_role"] = r;
+            }
+            if (j.contains("local_base")) {
+                int b = j["local_base"].get<int>();
+                if (b == 0 || b == 8) full["local_base"] = b;
+            }
+            if (j.contains("peer") && j["peer"].is_object()) {
+                if (!full.contains("peer") || !full["peer"].is_object())
+                    full["peer"] = json::object();
+                auto& p = j["peer"];
+                for (auto& k : {"enabled","host","port","username","password",
+                                "local_base","count","fps"})
+                    if (p.contains(k)) full["peer"][k] = p[k];
+            }
+            saveJson(config_path, full);
+            cout << "[Node] 角色配置已保存(重启生效)" << endl;
+            return R"({"ok":true,"restart_required":true})";
+        } catch (exception& e) {
+            return string(R"({"ok":false,"error":")") + e.what() + R"("})";
+        }
+    });
+
+    // 对端报警截图代理
+    web.setGetHandlerQ("/api/peer/image", [&](const map<string, string>& q) -> string {
+        auto it = q.find("file");
+        if (it == q.end() || it->second.empty()) return R"({"ok":false})";
+        // 仅允许文件名, 防目录穿越
+        string f = it->second;
+        if (f.find('/') != string::npos || f.find("..") != string::npos)
+            return R"({"ok":false})";
+        if (!remote) return R"({"ok":false})";
+        string raw, ctype;
+        if (!remote->getRaw("/data/screenshots/" + f, raw, ctype))
+            return R"({"ok":false})";
+        // 以base64返回, 避免与JSON混用
+        static const char* b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+        string out;
+        size_t i = 0;
+        while (i + 2 < raw.size()) {
+            unsigned v = (unsigned char)raw[i] << 16 |
+                         (unsigned char)raw[i+1] << 8 |
+                         (unsigned char)raw[i+2];
+            out += b64[(v >> 18) & 63]; out += b64[(v >> 12) & 63];
+            out += b64[(v >> 6) & 63];  out += b64[v & 63];
+            i += 3;
+        }
+        if (i < raw.size()) {
+            unsigned v = (unsigned char)raw[i] << 16;
+            bool two = (i + 1 < raw.size());
+            if (two) v |= (unsigned char)raw[i+1] << 8;
+            out += b64[(v >> 18) & 63]; out += b64[(v >> 12) & 63];
+            out += two ? b64[(v >> 6) & 63] : '=';
+            out += '=';
+        }
+        json r;
+        r["ok"] = true;
+        r["ctype"] = ctype;
+        r["b64"] = out;
+        return r.dump();
+    });
+
     web.setGetHandler("/api/system", [&]() -> string {
         json s;
         s["reboot_enabled"] = reboot_enabled;
@@ -1574,6 +1762,34 @@ int main(int argc, char* argv[]) {
         return R"({"ok":true})";
     });
 
+    // URL编码(报警时间查询参数含空格)
+    auto urlenc = [](const string& v) -> string {
+        static const char* hex = "0123456789ABCDEF";
+        string o;
+        for (unsigned char c : v) {
+            if (isalnum(c) || c=='-' || c=='_' || c=='.' || c=='~' || c==':')
+                o += (char)c;
+            else { o += '%'; o += hex[c >> 4]; o += hex[c & 15]; }
+        }
+        return o;
+    };
+    // 拼接报警查询串(不含channel, 由调用方决定)
+    auto buildAlarmQs = [&](const map<string,string>& q, int lch) -> string {
+        auto gets = [&](const string& k) -> string {
+            auto it = q.find(k); return it == q.end() ? string() : it->second;
+        };
+        string qs;
+        auto add = [&](const string& k, const string& v) {
+            if (v.empty()) return;
+            qs += qs.empty() ? "?" : "&";
+            qs += k + "=" + urlenc(v);
+        };
+        add("channel", lch >= 0 ? to_string(lch) : string());
+        add("start", gets("start"));
+        add("end", gets("end"));
+        return qs;
+    };
+
     web.setGetHandlerQ("/api/alarms", [&](const map<string, string>& q) -> string {
         auto geti = [&](const string& k, int def) -> int {
             auto it = q.find(k);
@@ -1589,21 +1805,49 @@ int main(int argc, char* argv[]) {
         string start = gets("start");
         string end = gets("end");
 
+        bool wantLocal = (ch < 0) || (ch >= local_base && ch < local_base + 8);
+        bool wantPeer = peer_active && ((ch < 0) || (ch >= peer_base && ch < peer_base + 8));
+
         json arr = json::array();
-        auto alarms = alarmHistory.getAlarms(limit, 0, ch, start, end);
-        for (auto& a : alarms) {
-            json aj;
-            aj["id"] = a.id;
-            aj["timestamp"] = a.timestamp;
-            aj["channel"] = a.channel;
-            aj["fence_name"] = a.fence_name;
-            aj["class_name"] = a.class_name;
-            aj["confidence"] = a.confidence;
-            aj["screenshot"] = a.screenshot_path;
-            arr.push_back(aj);
+        int total = 0;
+        if (wantLocal) {
+            int lch = (ch < 0) ? -1 : (ch - local_base);
+            auto alarms = alarmHistory.getAlarms(limit, 0, lch, start, end);
+            for (auto& a : alarms) {
+                json aj;
+                aj["id"] = a.id;
+                aj["timestamp"] = a.timestamp;
+                aj["channel"] = a.channel + local_base;
+                aj["fence_name"] = a.fence_name;
+                aj["class_name"] = a.class_name;
+                aj["confidence"] = a.confidence;
+                aj["screenshot"] = a.screenshot_path;
+                aj["src"] = "local";
+                arr.push_back(aj);
+            }
+            total += alarmHistory.getAlarmCount(lch, start, end);
+        }
+        if (wantPeer && remote) {
+            json pj;
+            string path = "/api/alarms" + buildAlarmQs(q, ch);
+            if (remote->getJson(path, pj)) {
+                total += pj.value("total", 0);
+                for (auto& a : pj["items"]) {
+                    a["src"] = "peer";
+                    arr.push_back(a);
+                }
+            }
+        }
+        std::sort(arr.begin(), arr.end(), [](const json& a, const json& b) {
+            return a.value("timestamp", string()) > b.value("timestamp", string());
+        });
+        if ((int)arr.size() > limit) {
+            json cut = json::array();
+            for (int i = 0; i < limit; i++) cut.push_back(arr[i]);
+            arr = cut;
         }
         json r;
-        r["total"] = alarmHistory.getAlarmCount(ch, start, end);
+        r["total"] = total;
         r["items"] = arr;
         return r.dump();
     });
@@ -1612,6 +1856,13 @@ int main(int argc, char* argv[]) {
         try {
             json j = json::parse(body);
             int64_t id = j.value("id", 0);
+            string src = j.value("src", "local");
+            if (src == "peer") {
+                json fwd; fwd["id"] = id;
+                if (remote && remote->postJson("/api/alarms/delete", fwd.dump()))
+                    return R"({"ok":true})";
+                return R"({"ok":false})";
+            }
             if (alarmHistory.deleteAlarm(id)) return R"({"ok":true})";
         } catch (...) {}
         return R"({"ok":false})";
@@ -1619,12 +1870,18 @@ int main(int argc, char* argv[]) {
 
     web.setPostHandler("/api/alarms/clear", [&](const string& body) -> string {
         alarmHistory.clearAll();
+        if (remote) remote->postJson("/api/alarms/clear", "{}");
         return R"({"ok":true})";
     });
 
     web.setGetHandler("/api/alarms/count", [&]() -> string {
         json r;
-        r["count"] = alarmHistory.getAlarmCount();
+        int n = alarmHistory.getAlarmCount();
+        if (peer_active && remote) {
+            json pj;
+            if (remote->getJson("/api/alarms/count", pj)) n += pj.value("count", 0);
+        }
+        r["count"] = n;
         return r.dump();
     });
 
@@ -1645,20 +1902,49 @@ int main(int argc, char* argv[]) {
         string start = gets("start");
         string end = gets("end");
 
+        bool wantLocal = (ch < 0) || (ch >= local_base && ch < local_base + 8);
+        bool wantPeer = peer_active && ((ch < 0) || (ch >= peer_base && ch < peer_base + 8));
+
         json arr = json::array();
-        auto list = alarmHistory.getPoseAlarms(limit, 0, ch, action, start, end);
-        for (auto& a : list) {
-            json aj;
-            aj["id"] = a.id;
-            aj["timestamp"] = a.timestamp;
-            aj["channel"] = a.channel;
-            aj["action"] = a.action;
-            aj["score"] = a.score;
-            aj["screenshot"] = a.screenshot_path;
-            arr.push_back(aj);
+        int total = 0;
+        if (wantLocal) {
+            int lch = (ch < 0) ? -1 : (ch - local_base);
+            auto list = alarmHistory.getPoseAlarms(limit, 0, lch, action, start, end);
+            for (auto& a : list) {
+                json aj;
+                aj["id"] = a.id;
+                aj["timestamp"] = a.timestamp;
+                aj["channel"] = a.channel + local_base;
+                aj["action"] = a.action;
+                aj["score"] = a.score;
+                aj["screenshot"] = a.screenshot_path;
+                aj["src"] = "local";
+                arr.push_back(aj);
+            }
+            total += alarmHistory.getPoseAlarmCount(lch, action, start, end);
+        }
+        if (wantPeer && remote) {
+            json pj;
+            string qs = buildAlarmQs(q, ch);
+            if (!action.empty()) qs += (qs.empty() ? "?" : "&") + string("action=") + urlenc(action);
+            if (remote->getJson("/api/pose/alarms" + qs, pj)) {
+                total += pj.value("total", 0);
+                for (auto& a : pj["items"]) {
+                    a["src"] = "peer";
+                    arr.push_back(a);
+                }
+            }
+        }
+        std::sort(arr.begin(), arr.end(), [](const json& a, const json& b) {
+            return a.value("timestamp", string()) > b.value("timestamp", string());
+        });
+        if ((int)arr.size() > limit) {
+            json cut = json::array();
+            for (int i = 0; i < limit; i++) cut.push_back(arr[i]);
+            arr = cut;
         }
         json r;
-        r["total"] = alarmHistory.getPoseAlarmCount(ch, action, start, end);
+        r["total"] = total;
         r["items"] = arr;
         return r.dump();
     });
@@ -1667,6 +1953,13 @@ int main(int argc, char* argv[]) {
         try {
             json j = json::parse(body);
             int64_t id = j.value("id", 0);
+            string src = j.value("src", "local");
+            if (src == "peer") {
+                json fwd; fwd["id"] = id;
+                if (remote && remote->postJson("/api/pose/alarms/delete", fwd.dump()))
+                    return R"({"ok":true})";
+                return R"({"ok":false})";
+            }
             if (alarmHistory.deletePoseAlarm(id)) return R"({"ok":true})";
         } catch (...) {}
         return R"({"ok":false})";
@@ -1674,12 +1967,18 @@ int main(int argc, char* argv[]) {
 
     web.setPostHandler("/api/pose/alarms/clear", [&](const string&) -> string {
         alarmHistory.clearPoseAlarms();
+        if (remote) remote->postJson("/api/pose/alarms/clear", "{}");
         return R"({"ok":true})";
     });
 
     web.setGetHandler("/api/pose/alarms/count", [&]() -> string {
         json r;
-        r["count"] = alarmHistory.getPoseAlarmCount();
+        int n = alarmHistory.getPoseAlarmCount();
+        if (peer_active && remote) {
+            json pj;
+            if (remote->getJson("/api/pose/alarms/count", pj)) n += pj.value("count", 0);
+        }
+        r["count"] = n;
         return r.dump();
     });
 
@@ -1883,12 +2182,74 @@ int main(int argc, char* argv[]) {
     });
 
     web.start();
-    // 网格布局固定为已配置的rtsp通道数, 避免启动/断帧时布局跳动
-    int grid_fixed = (int)channels.size();
+    // 网格布局固定为本地+对端通道数, 避免启动/断帧时布局跳动
+    int grid_fixed = (int)channels.size() + (peer_active ? peer_count : 0);
     displayGrid.start(grid_output, hdmi_grid_cols, app_name + " - 通道网格",
                       hdmi_fullscreen, 100, grid_fixed);
     displayBucket.start(steel_output && enable_bucket, 2, app_name + " - 钢包位",
                         hdmi_fullscreen, 100, 2);
+
+    // ========== 对端节点(双机16路): 拉取画面 + 供本地显示/推流 ==========
+    if (peer_active) {
+        remote = make_shared<RemoteClient>();
+        remote->configure(peer_host, peer_port, peer_user, peer_pass);
+        // 对端报警截图代理(主节点直接返回图片字节)
+        web.setPeerImageProvider([remote](const string& file,
+                                          string& bytes, string& ctype) {
+            if (!remote) return false;
+            return remote->getRaw("/data/screenshots/" + file, bytes, ctype);
+        });
+        peer_chans.resize(peer_count);
+        peer_clients.resize(peer_count);
+        for (int i = 0; i < peer_count; i++) {
+            auto pc = make_shared<PeerChan>();
+            auto pcli = make_shared<RemoteClient>();
+            pcli->configure(peer_host, peer_port, peer_user, peer_pass);
+            peer_clients[i] = pcli;
+            peer_chans[i] = pc;
+            int gid = peer_base + i;
+            // 注册对端通道的帧提供器(供 web/mjpeg/手机 访问)
+            web.setFrameProvider(gid, [pc]() -> cv::Mat {
+                lock_guard<mutex> lk(pc->m);
+                if (!pc->frame.empty()) return pc->frame.clone();
+                return cv::Mat(360, 640, CV_8UC3, cv::Scalar(20, 20, 20));
+            });
+            web.setFrameSeqProvider(gid, [pc]() -> uint64_t {
+                return pc->seq.load(std::memory_order_relaxed);
+            });
+            // 每路一个拉取线程
+            std::thread([&displayGrid, pc, pcli, i, gid, remote_fps, peer_base]() {
+                const int interval = 1000 / max(1, remote_fps);
+                int fail = 0;
+                while (g_running.load()) {
+                    cv::Mat img;
+                    bool ok = pcli->getJpeg("/stream/" + to_string(peer_base + i), img) && !img.empty();
+                    if (ok) {
+                        {
+                            lock_guard<mutex> lk(pc->m);
+                            pc->frame = img;
+                        }
+                        pc->seq.fetch_add(1, std::memory_order_relaxed);
+                        pc->last_ms.store(nowMs(), std::memory_order_relaxed);
+                        if (displayGrid.isRunning()) {
+                            char lbl[64];
+                            snprintf(lbl, sizeof(lbl), "CH%d %s", gid, "远端");
+                            displayGrid.setFrame(gid, img, lbl);
+                        }
+                        fail = 0;
+                        this_thread::sleep_for(chrono::milliseconds(interval));
+                    } else {
+                        fail++;
+                        this_thread::sleep_for(chrono::milliseconds(
+                            min(3000, 200 * fail)));
+                    }
+                }
+            }).detach();
+        }
+        cout << "[Peer] 已启动对端画面拉取: " << peer_count << "路 from "
+             << peer_host << ":" << peer_port << endl;
+    }
+
     cout << "[Main] Ctrl+C 退出" << endl;
 
     // ========== 看门狗: 主循环卡死(如rknn_run挂起)时自动退出, 由systemd重启 ==========
@@ -2533,10 +2894,11 @@ int main(int argc, char* argv[]) {
             }
             // 输出到本机显示器(HDMI)
             if (displayGrid.isRunning() && ch->id < 100) {
+                int gid = local_base + ch->id;
                 char dlbl[64];
                 snprintf(dlbl, sizeof(dlbl), "CH%d %s",
-                         ch->id, ch->capture->name().c_str());
-                displayGrid.setFrame(ch->id, display, dlbl);
+                         gid, ch->capture->name().c_str());
+                displayGrid.setFrame(gid, display, dlbl);
             }
             if (displayBucket.isRunning() && ch->id >= 100) {
                 char dlbl[64];

+ 106 - 0
src/remote_source.cpp

@@ -0,0 +1,106 @@
+#include "remote_source.h"
+#include "httplib.h"
+#include <iostream>
+
+using namespace std;
+using json = nlohmann::json;
+
+RemoteClient::~RemoteClient() = default;
+
+void RemoteClient::resetClient() {
+    cli_.reset();
+}
+
+void RemoteClient::configure(const string& host, int port,
+                             const string& user, const string& pass) {
+    lock_guard<mutex> lk(mtx_);
+    host_ = host;
+    port_ = port;
+    user_ = user;
+    pass_ = pass;
+    cookie_.clear();
+    resetClient();
+}
+
+bool RemoteClient::ensureAuth() {
+    if (!configured()) return false;
+    if (!cli_) {
+        cli_ = make_unique<httplib::Client>(host_, port_);
+        cli_->set_connection_timeout(3, 0);
+        cli_->set_read_timeout(5, 0);
+        cli_->set_write_timeout(5, 0);
+        cli_->set_keep_alive(true);
+    }
+    if (!cookie_.empty()) return true;
+    // 登录
+    json body = {{"username", user_}, {"password", pass_}};
+    auto r = cli_->Post("/api/login", body.dump(), "application/json");
+    if (!r || r->status != 200) return false;
+    string sc = r->get_header_value("Set-Cookie");
+    if (sc.empty()) return false;
+    size_t semi = sc.find(';');
+    cookie_ = (semi == string::npos) ? sc : sc.substr(0, semi);
+    try {
+        json j = json::parse(r->body);
+        return j.value("ok", false);
+    } catch (...) { return false; }
+}
+
+bool RemoteClient::getJson(const string& path, json& out) {
+    lock_guard<mutex> lk(mtx_);
+    for (int attempt = 0; attempt < 2; attempt++) {
+        if (!ensureAuth()) return false;
+        httplib::Headers h = {{"Cookie", cookie_}};
+        auto r = cli_->Get(path.c_str(), h);
+        if (!r) { resetClient(); cookie_.clear(); continue; }
+        if (r->status == 401) { cookie_.clear(); continue; }
+        if (r->status != 200) return false;
+        try { out = json::parse(r->body); return true; }
+        catch (...) { return false; }
+    }
+    return false;
+}
+
+bool RemoteClient::getRaw(const string& path, string& out, string& ctype) {
+    lock_guard<mutex> lk(mtx_);
+    for (int attempt = 0; attempt < 2; attempt++) {
+        if (!ensureAuth()) return false;
+        httplib::Headers h = {{"Cookie", cookie_}};
+        auto r = cli_->Get(path.c_str(), h);
+        if (!r) { resetClient(); cookie_.clear(); continue; }
+        if (r->status == 401) { cookie_.clear(); continue; }
+        if (r->status != 200) return false;
+        out = r->body;
+        ctype = r->get_header_value("Content-Type");
+        if (ctype.empty()) ctype = "application/octet-stream";
+        return true;
+    }
+    return false;
+}
+
+bool RemoteClient::getJpeg(const string& path, cv::Mat& out) {
+    string raw, ctype;
+    if (!getRaw(path, raw, ctype)) return false;
+    if (raw.empty()) return false;
+    vector<uchar> buf(raw.begin(), raw.end());
+    out = cv::imdecode(buf, cv::IMREAD_COLOR);
+    return !out.empty();
+}
+
+bool RemoteClient::postJson(const string& path, const string& body, json* out) {
+    lock_guard<mutex> lk(mtx_);
+    for (int attempt = 0; attempt < 2; attempt++) {
+        if (!ensureAuth()) return false;
+        httplib::Headers h = {{"Cookie", cookie_},
+                              {"Content-Type", "application/json"}};
+        auto r = cli_->Post(path.c_str(), h, body, "application/json");
+        if (!r) { resetClient(); cookie_.clear(); continue; }
+        if (r->status == 401) { cookie_.clear(); continue; }
+        if (r->status != 200) return false;
+        if (out) {
+            try { *out = json::parse(r->body); } catch (...) {}
+        }
+        return true;
+    }
+    return false;
+}

+ 39 - 0
src/remote_source.h

@@ -0,0 +1,39 @@
+#pragma once
+#include <string>
+#include <mutex>
+#include <memory>
+#include <opencv2/opencv.hpp>
+#include "json.hpp"
+#include "httplib.h"
+
+// 远程节点(对端板子)访问客户端: 登录 + 拉取 JSON / JPEG
+class RemoteClient {
+public:
+    RemoteClient() = default;
+    ~RemoteClient();
+
+    void configure(const std::string& host, int port,
+                   const std::string& user, const std::string& pass);
+    bool configured() const { return !host_.empty() && port_ > 0; }
+
+    // 获取JSON (GET path)
+    bool getJson(const std::string& path, nlohmann::json& out);
+    // 获取原始字节(用于截图等)
+    bool getRaw(const std::string& path, std::string& out, std::string& ctype);
+    // 获取并解码JPEG
+    bool getJpeg(const std::string& path, cv::Mat& out);
+    // POST JSON
+    bool postJson(const std::string& path, const std::string& body,
+                  nlohmann::json* out = nullptr);
+
+private:
+    bool ensureAuth();
+    void resetClient();
+
+    std::string host_;
+    int port_ = 0;
+    std::string user_, pass_;
+    std::string cookie_;
+    std::mutex mtx_;
+    std::unique_ptr<httplib::Client> cli_;
+};

+ 43 - 0
src/web_server.cpp

@@ -25,6 +25,8 @@ public:
     unordered_map<int, function<cv::Mat()>> frame_providers;
     unordered_map<int, function<uint64_t()>> seq_providers;
 
+    std::function<bool(const std::string&, std::string&, std::string&)> peer_image_fn;
+
     AuthHooks hooks_;
     bool hooks_set_ = false;
 
@@ -375,6 +377,42 @@ public:
                 res.set_content(it->second(req.body), "application/json");
         });
 
+        svr.Get("/api/peer/image/:file", [this](const httplib::Request& req, httplib::Response& res) {
+            if (!authed(req)) { denyJson(res); return; }
+            if (!peer_image_fn) { res.status = 404; return; }
+            std::string file = req.path_params.at("file");
+            if (file.find("..") != std::string::npos) { res.status = 403; return; }
+            std::string bytes, ctype;
+            if (!peer_image_fn || !peer_image_fn(file, bytes, ctype)) {
+                res.status = 404; return;
+            }
+            res.set_content(bytes, ctype.empty() ? "image/jpeg" : ctype.c_str());
+        });
+
+        svr.Get("/api/node", [this](const httplib::Request& req, httplib::Response& res) {
+            if (!authed(req)) { denyJson(res); return; }
+            auto it = get_handlers.find("/api/node");
+            if (it != get_handlers.end())
+                res.set_content(it->second(), "application/json");
+        });
+
+        svr.Post("/api/node", [this](const httplib::Request& req, httplib::Response& res) {
+            if (!authed(req)) { denyJson(res); return; }
+            auto it = post_handlers.find("/api/node");
+            if (it != post_handlers.end())
+                res.set_content(it->second(req.body), "application/json");
+        });
+
+        svr.Get("/api/peer/image", [this](const httplib::Request& req, httplib::Response& res) {
+            if (!authed(req)) { denyJson(res); return; }
+            auto it = getq_handlers.find("/api/peer/image");
+            if (it != getq_handlers.end()) {
+                std::map<std::string, std::string> ps;
+                for (auto& p : req.params) ps[p.first] = p.second;
+                res.set_content(it->second(ps), "application/json");
+            }
+        });
+
         svr.Get("/api/system", [this](const httplib::Request& req, httplib::Response& res) {
             if (!authed(req)) { denyJson(res); return; }
             auto it = get_handlers.find("/api/system");
@@ -619,6 +657,11 @@ void WebServer::setPostHandler(const string& path, function<string(const string&
     impl_->post_handlers[path] = handler;
 }
 
+void WebServer::setPeerImageProvider(
+    function<bool(const string&, string&, string&)> fn) {
+    impl_->peer_image_fn = std::move(fn);
+}
+
 void WebServer::setFrameProvider(int channel, function<cv::Mat()> provider) {
     if (channel >= 0)
         impl_->frame_providers[channel] = std::move(provider);

+ 4 - 0
src/web_server.h

@@ -35,6 +35,10 @@ public:
     void setGetHandlerQ(const std::string& path,
                         std::function<std::string(const std::map<std::string, std::string>&)> handler);
 
+    // 对端截图代理: (文件名) -> 是否成功, 输出字节与类型
+    void setPeerImageProvider(
+        std::function<bool(const std::string&, std::string&, std::string&)> fn);
+
     void setFrameProvider(int channel, std::function<cv::Mat()> provider);
     void setFrameSeqProvider(int channel, std::function<uint64_t()> provider);
     void removeFrameProvider(int channel);

+ 84 - 11
web/index.html

@@ -407,6 +407,34 @@ border-left:3px solid #e94560;padding:8px 10px;margin-bottom:8px;cursor:pointer}
           </div>
           <div class="btn-row"><button class="btn btn-primary" onclick="saveSystemOptions()">保存</button></div>
         </div>
+        <div class="card">
+          <h3>双机模式(16路单屏)</h3>
+          <div class="hint">保存后需重启生效。主节点负责显示16格与报警聚合;从节点仅采集推理。</div>
+          <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
+            <div class="form-group" style="margin:0"><label>本机角色</label>
+              <select id="sysNodeRole">
+                <option value="standalone">单机</option>
+                <option value="master">主节点(接屏+聚合)</option>
+                <option value="slave">从节点(无屏)</option>
+              </select></div>
+            <div class="form-group" style="margin:0"><label>本机通道起点(0或8)</label>
+              <input type="number" id="sysLocalBase" min="0" max="8" step="8" value="0"></div>
+          </div>
+          <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
+            <div class="form-group" style="margin:0"><label>对端IP</label><input type="text" id="sysPeerHost" placeholder="192.168.1.xxx"></div>
+            <div class="form-group" style="margin:0"><label>对端端口</label><input type="number" id="sysPeerPort" min="1" max="65535" value="8080"></div>
+          </div>
+          <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
+            <div class="form-group" style="margin:0"><label>对端用户</label><input type="text" id="sysPeerUser" value="admin"></div>
+            <div class="form-group" style="margin:0"><label>对端密码</label><input type="password" id="sysPeerPass" value="admin"></div>
+          </div>
+          <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px">
+            <div class="form-group" style="margin:0"><label>对端起点</label><input type="number" id="sysPeerBase" min="0" max="8" step="8" value="8"></div>
+            <div class="form-group" style="margin:0"><label>对端通道数</label><input type="number" id="sysPeerCount" min="1" max="8" value="8"></div>
+            <div class="form-group" style="margin:0"><label>拉流帧率</label><input type="number" id="sysPeerFps" min="1" max="15" value="8"></div>
+          </div>
+          <div class="btn-row"><button class="btn btn-primary" onclick="saveNodeConfig()">保存(重启生效)</button></div>
+        </div>
         <div class="card">
           <h3>自动重启</h3>
           <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
@@ -630,10 +658,10 @@ function selectChannel(id) {
 
 function layoutGrid() {
   const n = CHANNELS.length;
-  const cols = n <= 1 ? '1fr' : n <= 4 ? 'repeat(2, 1fr)' : 'repeat(3, 1fr)';
+  const cols = n <= 1 ? '1fr' : n <= 4 ? 'repeat(2, 1fr)' : (n <= 9 ? 'repeat(3, 1fr)' : 'repeat(4, 1fr)');
   const gw = document.getElementById('gridWrap');
   gw.style.gridTemplateColumns = cols;
-  gw.style.gridAutoRows = n <= 1 ? '100%' : '50%';
+  gw.style.gridAutoRows = n <= 1 ? '100%' : (n <= 4 ? '50%' : (n <= 9 ? '33.33%' : '25%'));
 }
 
 function connectStream(id) {
@@ -1024,14 +1052,14 @@ async function loadAllAlarms() {
       type: 'fence',
       timestamp: a.timestamp, channel: a.channel,
       label: a.fence_name, detail: a.class_name,
-      conf: a.confidence, screenshot: a.screenshot,
+      conf: a.confidence, screenshot: a.screenshot, src: a.src || 'local',
       actionColor: '#27ae60', id: a.id
     }));
     const poseItems = (pd.items || []).map(a => ({
       type: 'pose',
       timestamp: a.timestamp, channel: a.channel,
       label: a.action, detail: '姿态',
-      conf: a.score, screenshot: a.screenshot,
+      conf: a.score, screenshot: a.screenshot, src: a.src || 'local',
       actionColor: poseActionColor(a.action), id: a.id
     }));
     let items = type === 'fence' ? fenceItems
@@ -1049,35 +1077,38 @@ async function loadAllAlarms() {
       const tag = a.type === 'pose'
         ? `<span style="color:${a.actionColor};font-size:11px;border:1px solid ${a.actionColor};padding:0 6px;border-radius:3px">姿态</span>`
         : `<span style="color:#27ae60;font-size:11px;border:1px solid #27ae60;padding:0 6px;border-radius:3px">围栏</span>`;
+      const imgUrl = (a.src === 'peer')
+        ? '/api/peer/image/' + a.screenshot.split('/').pop()
+        : '/' + a.screenshot;
       return `
       <div class="alist-row" style="border-left-color:${a.actionColor}"
-           onclick="showAlarm('${a.screenshot}','${a.timestamp}',${a.channel},'${a.label}','${a.detail}',${a.conf})">
-        <img src="/${a.screenshot}" loading="lazy" alt="">
+           onclick="showAlarm('${imgUrl}','${a.timestamp}',${a.channel},'${a.label}','${a.detail}',${a.conf})">
+        <img src="${imgUrl}" loading="lazy" alt="">
         <div class="col-t">${a.timestamp}</div>
         <div class="col-ch">CH${a.channel}<div class="nm">${c ? c.name : ''}</div></div>
         <div class="col-f" style="color:${a.actionColor}">${a.label}</div>
         <div class="col-c">${tag}</div>
         <div class="col-conf">${(a.conf*100).toFixed(0)}%</div>
         <button class="btn btn-outline" style="padding:3px 10px;font-size:12px"
-                onclick="event.stopPropagation();delAlarm(${a.id},'${a.type}')">删除</button>
+                onclick="event.stopPropagation();delAlarm(${a.id},'${a.type}','${a.src}')">删除</button>
       </div>`;
     }).join('');
   } catch (e) {}
 }
-function showAlarm(screenshot, time, ch, label, detail, conf) {
-  document.getElementById('alarmScreenshot').src = '/' + screenshot;
+function showAlarm(url, time, ch, label, detail, conf) {
+  document.getElementById('alarmScreenshot').src = url;
   const c = chById(ch);
   document.getElementById('alarmMeta').innerHTML =
     `时间: ${time}<br>通道: CH${ch} ${c ? c.name : ''}<br>类型: ${label}<br>详情: ${detail}<br>置信度: ${(conf*100).toFixed(1)}%`;
   document.getElementById('alarmModal').classList.add('show');
 }
 function closeAlarmModal() { document.getElementById('alarmModal').classList.remove('show'); }
-async function delAlarm(id, type) {
+async function delAlarm(id, type, src) {
   if (!confirm('删除此条报警?')) return;
   const url = type === 'pose' ? '/api/pose/alarms/delete' : '/api/alarms/delete';
   try {
     await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'},
-      body: JSON.stringify({id})});
+      body: JSON.stringify({id, src: src || 'local'})});
     loadAllAlarms();
   } catch (e) {}
 }
@@ -1598,6 +1629,47 @@ async function toggleSystemMjpeg() {
     await fetch('/api/mjpeg/toggle', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled})});
   } catch (e) {}
 }
+async function loadNodeConfig() {
+  try {
+    const r = await fetch('/api/node');
+    if (r.status === 401) return;
+    const d = await r.json();
+    if (document.getElementById('sysNodeRole')) document.getElementById('sysNodeRole').value = d.role || 'standalone';
+    if (document.getElementById('sysLocalBase')) document.getElementById('sysLocalBase').value = d.local_base || 0;
+    const p = d.peer || {};
+    if (document.getElementById('sysPeerHost')) document.getElementById('sysPeerHost').value = p.host || '';
+    if (document.getElementById('sysPeerPort')) document.getElementById('sysPeerPort').value = p.port || 8080;
+    if (document.getElementById('sysPeerUser')) document.getElementById('sysPeerUser').value = p.username || 'admin';
+    if (document.getElementById('sysPeerPass')) document.getElementById('sysPeerPass').value = p.password || '';
+    if (document.getElementById('sysPeerBase')) document.getElementById('sysPeerBase').value = (p.local_base !== undefined ? p.local_base : 8);
+    if (document.getElementById('sysPeerCount')) document.getElementById('sysPeerCount').value = p.count || 8;
+    if (document.getElementById('sysPeerFps')) document.getElementById('sysPeerFps').value = p.fps || 8;
+  } catch (e) {}
+}
+async function saveNodeConfig() {
+  const body = {
+    role: document.getElementById('sysNodeRole').value,
+    local_base: parseInt(document.getElementById('sysLocalBase').value) || 0,
+    peer: {
+      enabled: document.getElementById('sysNodeRole').value === 'master'
+               && !!document.getElementById('sysPeerHost').value,
+      host: document.getElementById('sysPeerHost').value.trim(),
+      port: parseInt(document.getElementById('sysPeerPort').value) || 8080,
+      username: document.getElementById('sysPeerUser').value || 'admin',
+      password: document.getElementById('sysPeerPass').value || 'admin',
+      local_base: parseInt(document.getElementById('sysPeerBase').value) || 8,
+      count: parseInt(document.getElementById('sysPeerCount').value) || 8,
+      fps: parseInt(document.getElementById('sysPeerFps').value) || 8
+    }
+  };
+  try {
+    const r = await fetch('/api/node', {method:'POST',
+      headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
+    const d = await r.json();
+    alert(d.ok ? '已保存,重启后生效' : ('保存失败: ' + (d.error||'')));
+  } catch (e) { alert('保存失败'); }
+}
+
 async function saveSystemConfig() {
   const body = {
     auto_reboot: {
@@ -1705,6 +1777,7 @@ loadFences();
 loadAuthInfo();
 loadSystemOptions();
 loadSystemConfig();
+loadNodeConfig();
 loadTargetConfig();
 loadPoseConfig();
 loadRelayStatus();

+ 6 - 6
web/mobile.html

@@ -319,8 +319,8 @@ function renderAlarmPage() {
   alarmHasMore = start + alarmPageSize < alarmItems.length;
   const list = document.getElementById('alarmList');
   list.insertAdjacentHTML('beforeend', page.map(a => `
-    <div class="alarmItem" style="border-left-color:${a.color}" onclick="showDetail('${a.img}','${a.ts}','${a.label}','${a.detail}',${a.conf},${a.ch})">
-      <img src="/${a.img}" loading="lazy">
+    <div class="alarmItem" style="border-left-color:${a.color}" onclick="showDetail('${a.src === 'peer' ? '/api/peer/image/' + a.img.split('/').pop() : '/' + a.img}','${a.ts}','${a.label}','${a.detail}',${a.conf},${a.ch})">
+      <img src="${a.src === 'peer' ? '/api/peer/image/' + a.img.split('/').pop() : '/' + a.img}" loading="lazy">
       <div class="info">
         <div class="time">${a.ts} · CH${a.ch}</div>
         <div class="type" style="color:${a.color}">${a.label}</div>
@@ -348,8 +348,8 @@ async function loadAlarms(reset = false) {
   try {
     const [fr, pr] = await Promise.all([fetch('/api/alarms?' + qs), fetch('/api/pose/alarms?' + qs)]);
     const fd = await fr.json(), pd = await pr.json();
-    const fence = (fd.items||[]).map(a => ({t:'围栏', ts:a.timestamp, ch:a.channel, label:a.fence_name, detail:a.class_name, conf:a.confidence, img:a.screenshot, color:'#27ae60'}));
-    const pose = (pd.items||[]).map(a => ({t:a.action, ts:a.timestamp, ch:a.channel, label:a.action, detail:'姿态', conf:a.score, img:a.screenshot, color:poseColor(a.action)}));
+    const fence = (fd.items||[]).map(a => ({t:'围栏', ts:a.timestamp, ch:a.channel, label:a.fence_name, detail:a.class_name, conf:a.confidence, img:a.screenshot, src:a.src||'local', color:'#27ae60'}));
+    const pose = (pd.items||[]).map(a => ({t:a.action, ts:a.timestamp, ch:a.channel, label:a.action, detail:'姿态', conf:a.score, img:a.screenshot, src:a.src||'local', color:poseColor(a.action)}));
     alarmItems = type==='fence' ? fence : type==='pose' ? pose : [...fence, ...pose];
     alarmItems.sort((a,b) => b.ts.localeCompare(a.ts));
     if (!alarmItems.length) {
@@ -360,8 +360,8 @@ async function loadAlarms(reset = false) {
   alarmLoading = false;
 }
 
-function showDetail(img, ts, label, detail, conf, ch) {
-  document.getElementById('detailImg').src = '/' + img;
+function showDetail(url, ts, label, detail, conf, ch) {
+  document.getElementById('detailImg').src = url;
   document.getElementById('detailInfo').innerHTML =
     `<b>${label}</b> ${(conf*100).toFixed(0)}%<br>时间: ${ts}<br>通道: CH${ch}<br>详情: ${detail}`;
   document.getElementById('detailModal').classList.add('show');