Commit 744cee89 by James Yuan

feat: Modbus 模拟器与网关实采联调

本机三端口模拟约 800 点,Windows 网关真实轮询 Holding 并上报 snapshot;平台可切 gateway 模式对接。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent f2d7804e
...@@ -9,3 +9,5 @@ server/data/*.json ...@@ -9,3 +9,5 @@ server/data/*.json
web/dist web/dist
.vscode .vscode
.idea .idea
.venv
**/__pycache__/
...@@ -50,7 +50,7 @@ services: ...@@ -50,7 +50,7 @@ services:
JWT_SECRET: ${JWT_SECRET:-change-me-in-production} JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-12h} JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-12h}
DATA_MODE: ${DATA_MODE:-mock} DATA_MODE: ${DATA_MODE:-mock}
# 临时 Windows 网关已弃用;本机无网关时留空 # Windows 协议网关,例如 http://192.168.32.121:38080
GATEWAY_URL: ${GATEWAY_URL:-} GATEWAY_URL: ${GATEWAY_URL:-}
# 容器内走服务名;浏览器 ICE 仍用宿主机端口 8889 # 容器内走服务名;浏览器 ICE 仍用宿主机端口 8889
WEBRTC_WHEP_BASE: ${WEBRTC_WHEP_BASE:-http://mediamtx:8889} WEBRTC_WHEP_BASE: ${WEBRTC_WHEP_BASE:-http://mediamtx:8889}
......
...@@ -6,9 +6,9 @@ ...@@ -6,9 +6,9 @@
"adminUsername": "admin", "adminUsername": "admin",
"adminPassword": "Admin@123", "adminPassword": "Admin@123",
"modbusHosts": [ "modbusHosts": [
{ "id": "mb-102", "name": "PAU-201-1", "host": "192.168.10.102", "port": 502, "enabled": true }, { "id": "mb-102", "name": "PAU-201-1(本机模拟)", "host": "192.168.32.122", "port": 1502, "enabled": true },
{ "id": "mb-103", "name": "计量/通风柜", "host": "192.168.10.103", "port": 502, "enabled": true }, { "id": "mb-103", "name": "计量/通风柜(本机模拟)", "host": "192.168.32.122", "port": 1503, "enabled": true },
{ "id": "mb-104", "name": "房间/冰箱", "host": "192.168.10.104", "port": 502, "enabled": true } { "id": "mb-104", "name": "房间/冰箱(本机模拟)", "host": "192.168.32.122", "port": 1504, "enabled": true }
], ],
"nvr": { "nvr": {
"id": "nvr-140", "id": "nvr-140",
......
...@@ -3,11 +3,15 @@ ...@@ -3,11 +3,15 @@
* - Platform API: /health /snapshot /config/points/reload ... * - Platform API: /health /snapshot /config/points/reload ...
* - Admin UI: /ui/ and /admin/api/ * - Admin UI: /ui/ and /admin/api/
*/ */
// Winsock2 必须先于 windows.h / httplib
#include "modbus_tcp.hpp"
#include "httplib.h" #include "httplib.h"
#include <algorithm> #include <algorithm>
#include <cctype>
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
#include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
...@@ -16,12 +20,9 @@ ...@@ -16,12 +20,9 @@
#include <random> #include <random>
#include <sstream> #include <sstream>
#include <string> #include <string>
#include <thread>
#include <vector> #include <vector>
#ifdef _WIN32
#include <windows.h>
#endif
namespace { namespace {
struct PointCfg { struct PointCfg {
...@@ -37,6 +38,13 @@ struct PointCfg { ...@@ -37,6 +38,13 @@ struct PointCfg {
bool enabled = true; bool enabled = true;
}; };
struct PointValue {
double raw = 0;
double eng = 0;
std::string quality = "bad";
std::string ts;
};
struct ModbusHost { struct ModbusHost {
std::string id; std::string id;
std::string name; std::string name;
...@@ -77,8 +85,12 @@ struct AppState { ...@@ -77,8 +85,12 @@ struct AppState {
std::vector<HikDevice> doors; std::vector<HikDevice> doors;
int pointsVersion = 0; int pointsVersion = 0;
std::vector<PointCfg> points; std::vector<PointCfg> points;
std::string version = "0.2.0-ui"; std::map<std::string, PointValue> values; // key = point id
std::map<std::string, bool> hostOk;
std::map<std::string, int> hostLatencyMs;
std::string version = "0.3.0-modbus";
std::map<std::string, Session> sessions; std::map<std::string, Session> sessions;
bool pollRunning = false;
}; };
AppState g; AppState g;
...@@ -412,6 +424,154 @@ void unauthorized(httplib::Response& res) { ...@@ -412,6 +424,154 @@ void unauthorized(httplib::Response& res) {
res.set_content("{\"ok\":false,\"message\":\"未登录或会话过期\"}", "application/json; charset=utf-8"); res.set_content("{\"ok\":false,\"message\":\"未登录或会话过期\"}", "application/json; charset=utf-8");
} }
int wordCountForType(const std::string& dataType) {
std::string t = dataType;
for (char& c : t) c = static_cast<char>(::tolower(c));
if (t.find("double") != std::string::npos) return 4;
if (t.find("float") != std::string::npos) return 2;
return 1;
}
void decodePoint(const PointCfg& p, const std::vector<uint16_t>& regs, size_t off, PointValue& v,
const std::string& ts) {
const int n = wordCountForType(p.dataType);
if (off + static_cast<size_t>(n) > regs.size()) {
v.quality = "bad";
return;
}
std::string t = p.dataType;
for (char& c : t) c = static_cast<char>(::tolower(c));
double raw = 0;
double eng = 0;
if (t.find("float") != std::string::npos) {
eng = modbus_tcp::regsToFloat(regs[off], regs[off + 1]);
raw = eng;
} else if (t.find("double") != std::string::npos) {
eng = modbus_tcp::regsToDouble(regs[off], regs[off + 1], regs[off + 2], regs[off + 3]);
raw = eng;
} else if (t.find("bool") != std::string::npos) {
raw = regs[off] ? 1 : 0;
eng = raw;
} else if (t.find("short") != std::string::npos) {
raw = modbus_tcp::asI16(regs[off]);
eng = raw * p.scale + p.bias;
} else {
raw = regs[off];
eng = raw * p.scale + p.bias;
}
v.raw = raw;
v.eng = eng;
v.quality = "good";
v.ts = ts;
}
void pollOnce() {
std::vector<ModbusHost> hosts;
std::vector<PointCfg> points;
const int chunk = 100;
{
std::lock_guard<std::mutex> lock(g.mu);
hosts = g.modbusHosts;
points = g.points;
}
const std::string ts = isoNow();
std::map<std::string, PointValue> nextValues;
std::map<std::string, bool> hostOk;
std::map<std::string, int> hostLatency;
for (const auto& h : hosts) {
if (!h.enabled || h.host.empty()) continue;
std::vector<PointCfg> mine;
for (const auto& p : points) {
if (!p.enabled) continue;
if (p.gatewayId == h.id || (p.gatewayId.empty() && hosts.size() == 1)) mine.push_back(p);
}
bool anyOk = false;
int lastLat = 0;
int unit = mine.empty() ? 1 : mine.front().unitId;
if (mine.empty()) {
std::vector<uint16_t> tmp;
int lat = 0;
bool ok = modbus_tcp::readHolding(h.host, h.port, unit, 1, 1, tmp, &lat);
hostOk[h.id] = ok;
hostLatency[h.id] = lat;
continue;
}
int minAddr = mine.front().address;
int maxEnd = mine.front().address + wordCountForType(mine.front().dataType);
for (const auto& p : mine) {
minAddr = std::min(minAddr, p.address);
maxEnd = std::max(maxEnd, p.address + wordCountForType(p.dataType));
}
if (minAddr < 0) minAddr = 0;
std::map<int, uint16_t> flat;
for (int start = minAddr; start < maxEnd;) {
int cnt = std::min(chunk, maxEnd - start);
std::vector<uint16_t> regs;
int lat = 0;
if (modbus_tcp::readHolding(h.host, h.port, unit, start, cnt, regs, &lat) &&
static_cast<int>(regs.size()) == cnt) {
anyOk = true;
lastLat = lat;
for (int i = 0; i < cnt; ++i) flat[start + i] = regs[static_cast<size_t>(i)];
start += cnt;
} else {
// 单点回退,避免整段失败
std::vector<uint16_t> one;
if (modbus_tcp::readHolding(h.host, h.port, unit, start, 1, one, &lat) && !one.empty()) {
anyOk = true;
lastLat = lat;
flat[start] = one[0];
}
start += 1;
}
}
hostOk[h.id] = anyOk;
hostLatency[h.id] = lastLat;
for (const auto& p : mine) {
const int n = wordCountForType(p.dataType);
std::vector<uint16_t> regs(static_cast<size_t>(n), 0);
bool complete = true;
for (int i = 0; i < n; ++i) {
auto it = flat.find(p.address + i);
if (it == flat.end()) {
complete = false;
break;
}
regs[static_cast<size_t>(i)] = it->second;
}
PointValue v;
v.ts = ts;
if (complete) decodePoint(p, regs, 0, v, ts);
else v.quality = "bad";
nextValues[p.id.empty() ? p.itemId : p.id] = v;
}
}
{
std::lock_guard<std::mutex> lock(g.mu);
g.values.swap(nextValues);
g.hostOk.swap(hostOk);
g.hostLatencyMs.swap(hostLatency);
}
}
void pollLoop() {
while (true) {
try {
pollOnce();
} catch (...) {
std::cerr << "[gateway] modbus poll exception\n";
}
int ms = 2000;
{
std::lock_guard<std::mutex> lock(g.mu);
ms = g.pollMs > 200 ? g.pollMs : 200;
}
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}
}
std::string buildHealthJson() { std::string buildHealthJson() {
std::lock_guard<std::mutex> lock(g.mu); std::lock_guard<std::mutex> lock(g.mu);
std::ostringstream o; std::ostringstream o;
...@@ -419,8 +579,16 @@ std::string buildHealthJson() { ...@@ -419,8 +579,16 @@ std::string buildHealthJson() {
<< "\",\"pointsVersion\":" << g.pointsVersion << ",\"modbus\":["; << "\",\"pointsVersion\":" << g.pointsVersion << ",\"modbus\":[";
for (size_t i = 0; i < g.modbusHosts.size(); ++i) { for (size_t i = 0; i < g.modbusHosts.size(); ++i) {
if (i) o << ','; if (i) o << ',';
o << "{\"host\":\"" << jsonEscape(g.modbusHosts[i].host) << "\",\"port\":" << g.modbusHosts[i].port const auto& h = g.modbusHosts[i];
<< ",\"ok\":" << (g.modbusHosts[i].enabled ? "true" : "false") << ",\"latencyMs\":0}"; bool ok = false;
int lat = 0;
auto it = g.hostOk.find(h.id);
if (it != g.hostOk.end()) ok = it->second;
auto lit = g.hostLatencyMs.find(h.id);
if (lit != g.hostLatencyMs.end()) lat = lit->second;
o << "{\"id\":\"" << jsonEscape(h.id) << "\",\"host\":\"" << jsonEscape(h.host)
<< "\",\"port\":" << h.port << ",\"ok\":" << (ok ? "true" : "false")
<< ",\"latencyMs\":" << lat << "}";
} }
o << "],\"hikvision\":{\"loggedIn\":0,\"alarms\":false,\"note\":\"HCNetSDK pending\"}}"; o << "],\"hikvision\":{\"loggedIn\":0,\"alarms\":false,\"note\":\"HCNetSDK pending\"}}";
return o.str(); return o.str();
...@@ -461,10 +629,21 @@ std::string buildSnapshotJson() { ...@@ -461,10 +629,21 @@ std::string buildSnapshotJson() {
if (!p.enabled) continue; if (!p.enabled) continue;
if (!first) o << ','; if (!first) o << ',';
first = false; first = false;
const double raw = static_cast<double>((p.address * 17 + 250) % 400); const std::string key = p.id.empty() ? p.itemId : p.id;
const double eng = raw * p.scale + p.bias; auto it = g.values.find(key);
double raw = 0;
double eng = 0;
std::string quality = "bad";
std::string pts = ts;
if (it != g.values.end()) {
raw = it->second.raw;
eng = it->second.eng;
quality = it->second.quality;
if (!it->second.ts.empty()) pts = it->second.ts;
}
o << "{\"id\":\"" << jsonEscape(p.id) << "\",\"itemId\":\"" << jsonEscape(p.itemId) o << "{\"id\":\"" << jsonEscape(p.id) << "\",\"itemId\":\"" << jsonEscape(p.itemId)
<< "\",\"raw\":" << raw << ",\"eng\":" << eng << ",\"quality\":\"good\",\"ts\":\"" << ts << "\",\"gatewayId\":\"" << jsonEscape(p.gatewayId) << "\",\"raw\":" << raw
<< ",\"eng\":" << eng << ",\"quality\":\"" << jsonEscape(quality) << "\",\"ts\":\"" << pts
<< "\"}"; << "\"}";
} }
o << "]}"; o << "]}";
...@@ -557,10 +736,26 @@ int main(int argc, char** argv) { ...@@ -557,10 +736,26 @@ int main(int argc, char** argv) {
size_t count = 0; size_t count = 0;
{ {
std::lock_guard<std::mutex> lock(g.mu); std::lock_guard<std::mutex> lock(g.mu);
// 同步平台下发的 gateways(host/port)
auto gwObjs = splitObjects(req.body, "gateways");
if (!gwObjs.empty()) {
g.modbusHosts.clear();
for (const auto& obj : gwObjs) {
ModbusHost m;
m.id = extractString(obj, "id");
m.name = extractString(obj, "name");
m.host = extractString(obj, "host");
m.port = extractInt(obj, "port", 502);
m.enabled = extractBool(obj, "enabled", true);
if (!m.id.empty() && !m.host.empty()) g.modbusHosts.push_back(m);
}
saveConfigLocked();
}
g.points = std::move(parsed); g.points = std::move(parsed);
g.pointsVersion += 1; g.pointsVersion += 1;
ver = g.pointsVersion; ver = g.pointsVersion;
count = g.points.size(); count = g.points.size();
g.values.clear();
} }
std::ostringstream o; std::ostringstream o;
o << "{\"ok\":true,\"pointsVersion\":" << ver << ",\"count\":" << count << "}"; o << "{\"ok\":true,\"pointsVersion\":" << ver << ",\"count\":" << count << "}";
...@@ -749,8 +944,16 @@ int main(int argc, char** argv) { ...@@ -749,8 +944,16 @@ int main(int argc, char** argv) {
port = g.listenPort; port = g.listenPort;
host = g.listenHost; host = g.listenHost;
} }
{
std::lock_guard<std::mutex> lock(g.mu);
if (!g.pollRunning) {
g.pollRunning = true;
std::thread(pollLoop).detach();
}
}
std::cout << "[gateway] JhLabGateway " << g.version << " http://" << host << ":" << port << "\n"; std::cout << "[gateway] JhLabGateway " << g.version << " http://" << host << ":" << port << "\n";
std::cout << "[gateway] Admin UI: http://127.0.0.1:" << port << "/ui/\n"; std::cout << "[gateway] Admin UI: http://127.0.0.1:" << port << "/ui/\n";
std::cout << "[gateway] Modbus poll thread started\n";
if (!svr.listen(host.c_str(), port)) { if (!svr.listen(host.c_str(), port)) {
std::cerr << "[gateway] failed to bind\n"; std::cerr << "[gateway] failed to bind\n";
return 1; return 1;
......
#pragma once
/**
* 最小 Modbus TCP 客户端(FC03 Holding Register),Winsock。
*/
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#endif
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
namespace modbus_tcp {
inline bool ensureWsa() {
#ifdef _WIN32
static bool ok = false;
static bool tried = false;
if (tried) return ok;
tried = true;
WSADATA wsa{};
ok = (WSAStartup(MAKEWORD(2, 2), &wsa) == 0);
return ok;
#else
return false;
#endif
}
inline uint16_t rdBe16(const uint8_t* p) {
return static_cast<uint16_t>((p[0] << 8) | p[1]);
}
inline void wrBe16(uint8_t* p, uint16_t v) {
p[0] = static_cast<uint8_t>((v >> 8) & 0xFF);
p[1] = static_cast<uint8_t>(v & 0xFF);
}
/** 读 Holding Registers;address 与主站配置一致(0-based PDU)。成功返回 true。 */
inline bool readHolding(const std::string& host, int port, int unitId, int address, int count,
std::vector<uint16_t>& out, int* latencyMs = nullptr) {
out.clear();
if (count <= 0 || count > 125) return false;
#ifdef _WIN32
if (!ensureWsa()) return false;
const auto t0 = GetTickCount64();
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) return false;
DWORD timeout = 2000;
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&timeout), sizeof(timeout));
setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char*>(&timeout), sizeof(timeout));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<u_short>(port));
if (InetPtonA(AF_INET, host.c_str(), &addr.sin_addr) != 1) {
// 尝试解析主机名
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo* res = nullptr;
if (getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || !res) {
closesocket(sock);
return false;
}
addr.sin_addr = reinterpret_cast<sockaddr_in*>(res->ai_addr)->sin_addr;
freeaddrinfo(res);
}
if (connect(sock, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
closesocket(sock);
return false;
}
static uint16_t txn = 1;
uint16_t tid = txn++;
uint8_t req[12];
wrBe16(req + 0, tid);
wrBe16(req + 2, 0); // protocol
wrBe16(req + 4, 6); // length
req[6] = static_cast<uint8_t>(unitId & 0xFF);
req[7] = 0x03; // FC03
wrBe16(req + 8, static_cast<uint16_t>(address));
wrBe16(req + 10, static_cast<uint16_t>(count));
int sent = send(sock, reinterpret_cast<const char*>(req), 12, 0);
if (sent != 12) {
closesocket(sock);
return false;
}
uint8_t hdr[7];
int got = 0;
while (got < 7) {
int n = recv(sock, reinterpret_cast<char*>(hdr + got), 7 - got, 0);
if (n <= 0) {
closesocket(sock);
return false;
}
got += n;
}
uint16_t len = rdBe16(hdr + 4);
if (len < 3 || len > 260) {
closesocket(sock);
return false;
}
std::vector<uint8_t> body(len);
// unit already in hdr[6]; need remaining len-1 bytes after unit
body[0] = hdr[6];
int need = static_cast<int>(len) - 1;
got = 0;
while (got < need) {
int n = recv(sock, reinterpret_cast<char*>(body.data() + 1 + got), need - got, 0);
if (n <= 0) {
closesocket(sock);
return false;
}
got += n;
}
closesocket(sock);
if (latencyMs) *latencyMs = static_cast<int>(GetTickCount64() - t0);
// body: [unit][fc][bc][data...]
if (body.size() < 3) return false;
if (body[1] & 0x80) return false; // exception
if (body[1] != 0x03) return false;
uint8_t bc = body[2];
if (bc != count * 2 || body.size() < static_cast<size_t>(3 + bc)) return false;
out.resize(count);
for (int i = 0; i < count; ++i) {
out[i] = rdBe16(body.data() + 3 + i * 2);
}
return true;
#else
(void)host;
(void)port;
(void)unitId;
(void)address;
(void)latencyMs;
return false;
#endif
}
inline float regsToFloat(uint16_t hi, uint16_t lo) {
uint32_t bits = (uint32_t(hi) << 16) | uint32_t(lo);
float f = 0;
std::memcpy(&f, &bits, sizeof(f));
return f;
}
inline double regsToDouble(uint16_t a, uint16_t b, uint16_t c, uint16_t d) {
uint64_t bits =
(uint64_t(a) << 48) | (uint64_t(b) << 32) | (uint64_t(c) << 16) | uint64_t(d);
double x = 0;
std::memcpy(&x, &bits, sizeof(x));
return x;
}
inline int16_t asI16(uint16_t v) { return static_cast<int16_t>(v); }
} // namespace modbus_tcp
...@@ -338,10 +338,14 @@ export async function updateGateway( ...@@ -338,10 +338,14 @@ export async function updateGateway(
} }
const cur = (await listGateways()).find((g) => g.id === id) const cur = (await listGateways()).find((g) => g.id === id)
if (!cur) return null if (!cur) return null
const next = { ...cur, ...patch, updatedAt: now() } const next = {
...cur,
...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)),
updatedAt: now(),
} as Gateway
await query( await query(
`UPDATE gateways SET name=$2, host=$3, port=$4, enabled=$5, meta=$6::jsonb, updated_at=NOW() WHERE id=$1`, `UPDATE gateways SET name=$2, host=$3, port=$4, enabled=$5, meta=$6::jsonb, updated_at=NOW() WHERE id=$1`,
[id, next.name, next.host, next.port, next.enabled, JSON.stringify(next.meta)], [id, next.name, next.host, next.port, next.enabled, JSON.stringify(next.meta ?? {})],
) )
return next return next
} }
......
# Modbus TCP 三网关模拟器
按现场协议 CSV 加载约 **800** 个点位,在本机开三个 Modbus TCP 从站,对应:
| 网关 | 现场 IP | 本机默认端口 | 点位数(约) | 内容 |
|---|---|---|---|---|
| mb-102 | 192.168.10.102 | **1502** | 302 | PAU / 深度除湿 |
| mb-103 | 192.168.10.103 | **1503** | 77 | 电表 / 能量计 / 通风柜 / 流量计 |
| mb-104 | 192.168.10.104 | **1504** | 421 | 房间屏 / PLC / 冰箱 / N·E 电 |
全部映射为 **Holding Register(4X)**;`Float`/`Double` 按 IEEE754 大端多寄存器写入。数值按正弦 + 噪声持续变化。
## 安装与运行
```bash
cd tools/modbus_sim
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# 默认读取仓库旁「设备对接」协议包
python -u simulate.py
# 或指定 CSV 根目录 / 端口 / 绑定地址
python -u simulate.py \
--csv-root "/path/to/澳门镜湖医院协议20261019" \
--host 0.0.0.0 \
--port-102 1502 --port-103 1503 --port-104 1504
```
## 联调
本机 Windows 网关或其它主站:
```
102 → 127.0.0.1:1502
103 → 127.0.0.1:1503
104 → 127.0.0.1:1504
```
快速读测:
```bash
python - <<'PY'
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient('127.0.0.1', port=1504)
assert c.connect()
r = c.read_holding_registers(701, count=8)
regs = r.registers
# Short 冰箱温度:有符号 /10 → ℃
temps = [((v - 65536) if v >= 32768 else v) / 10 for v in regs]
print(regs, temps)
c.close()
PY
```
`.104` 冰箱温度地址 `701–708`(Short,×0.1℃)会随时间缓慢变化。
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { api } from '@/api/http' import { api } from '@/api/http'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
...@@ -42,7 +42,7 @@ interface DevicePoint { ...@@ -42,7 +42,7 @@ interface DevicePoint {
} }
type Tab = 'devices' | 'points' | 'gateways' type Tab = 'devices' | 'points' | 'gateways'
type Dialog = 'import' | 'createPoint' | 'createDevice' | 'editPoint' | null type Dialog = 'import' | 'createPoint' | 'createDevice' | 'editPoint' | 'viewDevice' | null
const auth = useAuthStore() const auth = useAuthStore()
const tab = ref<Tab>('devices') const tab = ref<Tab>('devices')
...@@ -57,6 +57,9 @@ const devices = ref<Device[]>([]) ...@@ -57,6 +57,9 @@ const devices = ref<Device[]>([])
const points = ref<DevicePoint[]>([]) const points = ref<DevicePoint[]>([])
const pointsVersion = ref(1) const pointsVersion = ref(1)
const gatewayHealth = ref<{ ok: boolean; message?: string; raw?: unknown } | null>(null) const gatewayHealth = ref<{ ok: boolean; message?: string; raw?: unknown } | null>(null)
const selectedDevice = ref<Device | null>(null)
const returnToDeviceView = ref(false)
let viewRefreshTimer: number | undefined
const filterGatewayId = ref('') const filterGatewayId = ref('')
const filterDeviceId = ref('') const filterDeviceId = ref('')
...@@ -106,11 +109,67 @@ const devicesWithPointCount = computed(() => ...@@ -106,11 +109,67 @@ const devicesWithPointCount = computed(() =>
})), })),
) )
const selectedDevicePoints = computed(() => {
if (!selectedDevice.value) return []
return points.value
.filter((p) => p.deviceId === selectedDevice.value!.id)
.sort((a, b) => a.modbusAddress - b.modbusAddress || a.name.localeCompare(b.name, 'zh'))
})
function clearFlash() { function clearFlash() {
message.value = '' message.value = ''
error.value = '' error.value = ''
} }
function formatEng(v: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return '—'
return Number.isInteger(v) ? String(v) : v.toFixed(2)
}
function formatTs(ts: string | null) {
if (!ts) return '—'
try {
return new Date(ts).toLocaleString('zh-CN', { hour12: false })
} catch {
return ts
}
}
function stopViewRefresh() {
if (viewRefreshTimer !== undefined) {
window.clearInterval(viewRefreshTimer)
viewRefreshTimer = undefined
}
}
async function refreshPointsOnly() {
try {
const pts = await api<{ points: DevicePoint[]; pointsVersion: number }>('/api/devices/points', {
token: auth.token,
})
points.value = pts.points
pointsVersion.value = pts.pointsVersion
} catch {
/* keep last values */
}
}
function startViewRefresh() {
stopViewRefresh()
void refreshPointsOnly()
viewRefreshTimer = window.setInterval(() => {
void refreshPointsOnly()
}, 3000)
}
function openDeviceDetail(d: Device) {
clearFlash()
selectedDevice.value = d
returnToDeviceView.value = false
dialog.value = 'viewDevice'
startViewRefresh()
}
function openDialog(kind: Dialog) { function openDialog(kind: Dialog) {
clearFlash() clearFlash()
if (kind === 'import') { if (kind === 'import') {
...@@ -140,13 +199,26 @@ function openDialog(kind: Dialog) { ...@@ -140,13 +199,26 @@ function openDialog(kind: Dialog) {
} }
function closeDialog() { function closeDialog() {
if (dialog.value === 'editPoint' && returnToDeviceView.value && selectedDevice.value) {
editPoint.value = null
error.value = ''
returnToDeviceView.value = false
dialog.value = 'viewDevice'
startViewRefresh()
return
}
stopViewRefresh()
dialog.value = null dialog.value = null
editPoint.value = null editPoint.value = null
selectedDevice.value = null
returnToDeviceView.value = false
error.value = '' error.value = ''
} }
function openEditPoint(p: DevicePoint) { function openEditPoint(p: DevicePoint) {
clearFlash() clearFlash()
returnToDeviceView.value = dialog.value === 'viewDevice'
stopViewRefresh()
editPoint.value = { ...p } editPoint.value = { ...p }
dialog.value = 'editPoint' dialog.value = 'editPoint'
} }
...@@ -204,8 +276,17 @@ async function savePoint() { ...@@ -204,8 +276,17 @@ async function savePoint() {
}), }),
}) })
message.value = '点位已保存' message.value = '点位已保存'
editPoint.value = null
error.value = ''
if (returnToDeviceView.value && selectedDevice.value) {
returnToDeviceView.value = false
dialog.value = 'viewDevice'
await loadAll()
startViewRefresh()
} else {
closeDialog() closeDialog()
await loadAll() await loadAll()
}
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '保存失败' error.value = e instanceof Error ? e.message : '保存失败'
} finally { } finally {
...@@ -363,6 +444,7 @@ function deviceName(id: string | null) { ...@@ -363,6 +444,7 @@ function deviceName(id: string | null) {
} }
onMounted(loadAll) onMounted(loadAll)
onUnmounted(stopViewRefresh)
</script> </script>
<template> <template>
...@@ -465,7 +547,12 @@ onMounted(loadAll) ...@@ -465,7 +547,12 @@ onMounted(loadAll)
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="d in devicesWithPointCount" :key="d.id"> <tr
v-for="d in devicesWithPointCount"
:key="d.id"
class="row-click"
@click="openDeviceDetail(d)"
>
<td>{{ d.name }}</td> <td>{{ d.name }}</td>
<td class="mono">{{ d.code }}</td> <td class="mono">{{ d.code }}</td>
<td>{{ d.pointCount }}</td> <td>{{ d.pointCount }}</td>
...@@ -539,6 +626,79 @@ onMounted(loadAll) ...@@ -539,6 +626,79 @@ onMounted(loadAll)
</div> </div>
</section> </section>
<!-- 设备详情 / 点位数值 -->
<div v-if="dialog === 'viewDevice' && selectedDevice" class="modal-mask" @click.self="closeDialog">
<div class="modal panel modal-lg">
<div class="panel-head">
<div class="panel-title">
<div class="vbar" />
<h3>{{ selectedDevice.name }}</h3>
<div class="badge">{{ selectedDevicePoints.length }} 个点位</div>
</div>
<div class="head-actions">
<button class="btn ghost" type="button" @click="refreshPointsOnly">刷新数值</button>
<button class="btn ghost" type="button" @click="closeDialog">关闭</button>
</div>
</div>
<div class="device-meta">
<span><small>编码</small><b class="mono">{{ selectedDevice.code }}</b></span>
<span>
<small>网关</small>
<b>{{ selectedDevice.gatewayId ? gatewayName(selectedDevice.gatewayId) : '—' }}</b>
</span>
<span><small>状态</small><b>{{ selectedDevice.status }}</b></span>
<span v-if="selectedDevice.meta?.sourceCsv">
<small>来源</small>
<b class="mono">{{ selectedDevice.meta.sourceCsv }}</b>
</span>
</div>
<div class="detail-table-wrap">
<table class="table">
<thead>
<tr>
<th>点位名称</th>
<th>地址</th>
<th>类型</th>
<th>工程值</th>
<th>倍率</th>
<th>更新时间</th>
<th>ItemID</th>
<th v-if="auth.isAdmin">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="p in selectedDevicePoints" :key="p.id">
<td>{{ p.name }}</td>
<td>{{ p.modbusAddress }}</td>
<td>{{ p.dataType }}</td>
<td class="eng" :class="{ muted: p.lastEng === null }">
{{ formatEng(p.lastEng) }}
</td>
<td>{{ p.scaleOverride ?? p.scale }}</td>
<td class="mono ts">{{ formatTs(p.lastTs) }}</td>
<td class="mono">{{ p.itemId }}</td>
<td v-if="auth.isAdmin" class="ops">
<button
class="btn ghost"
type="button"
@click.stop="openEditPoint(p)"
>
编辑
</button>
</td>
</tr>
<tr v-if="!selectedDevicePoints.length">
<td :colspan="auth.isAdmin ? 8 : 7" class="empty">该设备暂无绑定点位</td>
</tr>
</tbody>
</table>
</div>
<p class="hint">对话框打开期间每 3 秒自动刷新点位数值;无采集数据时工程值显示为 —。</p>
</div>
</div>
<!-- 导入 CSV --> <!-- 导入 CSV -->
<div v-if="dialog === 'import'" class="modal-mask" @click.self="closeDialog"> <div v-if="dialog === 'import'" class="modal-mask" @click.self="closeDialog">
<div class="modal panel modal-lg"> <div class="modal panel modal-lg">
...@@ -802,6 +962,46 @@ onMounted(loadAll) ...@@ -802,6 +962,46 @@ onMounted(loadAll)
white-space: nowrap; white-space: nowrap;
} }
.ops { display: flex; gap: 6px; } .ops { display: flex; gap: 6px; }
.row-click { cursor: pointer; }
.row-click:hover td { background: rgba(61, 190, 255, 0.12); }
.device-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
margin-bottom: 12px;
}
.device-meta span {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--panel-border);
border-radius: 2px;
background: rgba(6, 12, 20, 0.45);
}
.device-meta small {
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--panel-muted);
}
.device-meta b {
font-size: 12px;
font-weight: 600;
color: var(--panel-title);
}
.detail-table-wrap {
max-height: min(56vh, 520px);
overflow: auto;
}
.eng {
font-family: var(--digital-font), ui-monospace, monospace;
font-size: 14px;
font-weight: 700;
color: var(--accent);
}
.eng.muted { color: var(--panel-muted); font-weight: 600; }
.ts { font-size: 11px; color: var(--panel-muted); }
.empty { .empty {
text-align: center; text-align: center;
color: var(--panel-muted); color: var(--panel-muted);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment