Commit 1716214e by James Yuan

feat: 大屏同域云渲染飞视点、摄像头打点与沉浸式 UI

通过 /rtengine+/peer-stream+/designer 同域反代直调 $gisObj.flyToBookmark;
补齐摄像头场景坐标与后台管理,电表类点位只读,大屏铺满 RTEngine。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 49833298
...@@ -59,5 +59,8 @@ VITE_API_BASE= ...@@ -59,5 +59,8 @@ VITE_API_BASE=
VITE_WS_URL= VITE_WS_URL=
# 三维视口:glb | ue-stream | rtengine(RTEngineHD 云渲染 iframe) # 三维视口:glb | ue-stream | rtengine(RTEngineHD 云渲染 iframe)
VITE_VIEWPORT_MODE=rtengine VITE_VIEWPORT_MODE=rtengine
VITE_RTENGINE_URL=http://192.168.32.121:6088/gis.html # 推荐同域反代(postMessage 同域);开发/Nginx 已代理 /rtengine + /peer-stream + /designer
VITE_UE_STREAM_URL=http://192.168.32.121:6088/gis.html VITE_RTENGINE_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
VITE_UE_STREAM_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
# 反代上游引擎地址
VITE_RTENGINE_PROXY_TARGET=http://192.168.32.121:6088
...@@ -75,7 +75,8 @@ npm run dev ...@@ -75,7 +75,8 @@ npm run dev
- `DATABASE_URL` PostgreSQL 连接串 - `DATABASE_URL` PostgreSQL 连接串
- `DATA_MODE=mock|modbus` - `DATA_MODE=mock|modbus`
- `VITE_VIEWPORT_MODE=glb|ue-stream|rtengine` - `VITE_VIEWPORT_MODE=glb|ue-stream|rtengine`
- `VITE_RTENGINE_URL=` RTEngineHD 云渲染页(默认 `http://192.168.32.121:6088/gis.html`) - `VITE_RTENGINE_URL=` RTEngineHD 云渲染页(推荐同域 `/rtengine/gis.html?scene=mvg8x4b453b45f38`)
- `VITE_RTENGINE_PROXY_TARGET=` 反代上游(如 `http://192.168.32.121:6088`);开发代理 `/rtengine` + `/peer-stream` + `/designer`
- `VITE_UE_STREAM_URL=` UE/云渲染 iframe 备用地址 - `VITE_UE_STREAM_URL=` UE/云渲染 iframe 备用地址
- `ACCESS_BRIDGE_URL=` 门禁桥接 - `ACCESS_BRIDGE_URL=` 门禁桥接
......
...@@ -74,8 +74,8 @@ services: ...@@ -74,8 +74,8 @@ services:
args: args:
VITE_API_BASE: "" VITE_API_BASE: ""
VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-rtengine} VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-rtengine}
VITE_RTENGINE_URL: ${VITE_RTENGINE_URL:-http://192.168.32.121:6088/gis.html} VITE_RTENGINE_URL: ${VITE_RTENGINE_URL:-/rtengine/gis.html?scene=mvg8x4b453b45f38}
VITE_UE_STREAM_URL: ${VITE_UE_STREAM_URL:-http://192.168.32.121:6088/gis.html} VITE_UE_STREAM_URL: ${VITE_UE_STREAM_URL:-/rtengine/gis.html?scene=mvg8x4b453b45f38}
container_name: jh-lab-web container_name: jh-lab-web
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
|---|---| |---|---|
| id | 行号(导入时可忽略,平台用 UUID) | | id | 行号(导入时可忽略,平台用 UUID) |
| Name | 点名 | | Name | 点名 |
| RegType | 现场侧类型,如 `3X(Input Register)` | | RegType | 现场侧类型+功能码。`3X(Input Register)` / `1X` 为只读采集;`0X(Coil Status)-F5` 为写单线圈(控制);`4X(Holding Register)-F6` 为写寄存器(控制);`-F15/-F16` 为多写。**例外**:电表 / 流量计(水)/ 能量计 点表虽常标 `-F6`,点名均为电压/电流/流量/热量等采集量,`Description` 为空,业务上按只读处理 |
| RegAddress | 现场侧地址 | | RegAddress | 现场侧地址 |
| DataType | `Short` / `Word` / `Float` / `Double` / `Bit` 等 | | DataType | `Short` / `Word` / `Float` / `Double` / `Bit` 等 |
| Modbus ID | 从站号(unit id) | | Modbus ID | 从站号(unit id) |
...@@ -25,8 +25,8 @@ ...@@ -25,8 +25,8 @@
- `gateway_id`:逻辑 Modbus 主机(`.102/.103/.104`) - `gateway_id`:逻辑 Modbus 主机(`.102/.103/.104`)
- `device_id`:所属设备 - `device_id`:所属设备
- `snapshot_path`:映射大屏快照 JSON 路径 - `snapshot_path`:映射大屏快照 JSON 路径
- `rw`:`r` / `w` / `rw` - `rw`:`r` / `w` / `rw`(由 RegType 推断:含 `-F5/-F6/-F15/-F16` → `w`,周期采集跳过写控点;文件名含「电表|流量计|能量计」或点名为计量量时强制 `r`)
- `energy_kind`:`power` / `water` / `cooling` / null - `energy_kind`:`power` / `water` / `cooling` / null(无独立「水表」CSV,水相关用流量计)
- `enabled`:是否参与采集 - `enabled`:是否参与采集
- `scale_override`:覆盖 CSV Scale(解决 Scale=0) - `scale_override`:覆盖 CSV Scale(解决 Scale=0)
......
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient("127.0.0.1", port=1502, timeout=2)
print("connect", c.connect())
# pymodbus 3.6 uses slave=
r = c.read_holding_registers(1, 10, slave=1)
print("result", r)
if r and not r.isError():
print("regs", r.registers[:10])
c.close()
import socket
s = socket.create_connection(("127.0.0.1", 19999), 3)
print(s.recv(10))
s.close()
import socket
import threading
import time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 19999))
s.listen(5)
print("listening", flush=True)
def acc():
c, a = s.accept()
print("accepted", a, flush=True)
c.sendall(b"hi")
c.close()
threading.Thread(target=acc, daemon=True).start()
time.sleep(15)
print("done", flush=True)
"""单端口冒烟:确认本机 accept 正常。"""
import socket
import struct
import threading
import time
REGS = [i & 0xFFFF for i in range(2048)]
def handle(conn):
try:
hdr = conn.recv(7)
if len(hdr) < 7:
return
tid = struct.unpack(">H", hdr[0:2])[0]
length = struct.unpack(">H", hdr[4:6])[0]
unit = hdr[6]
body = b""
need = length - 1
while len(body) < need:
body += conn.recv(need - len(body))
addr = struct.unpack(">H", body[1:3])[0]
count = struct.unpack(">H", body[3:5])[0]
data = REGS[addr : addr + count]
payload = bytes([unit, 0x03, count * 2]) + b"".join(struct.pack(">H", w) for w in data)
conn.sendall(struct.pack(">HHH", tid, 0, len(payload)) + payload)
finally:
conn.close()
def main():
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 11502))
s.listen(8)
print("listening 11502", flush=True)
while True:
c, a = s.accept()
print("accepted", a, flush=True)
threading.Thread(target=handle, args=(c,), daemon=True).start()
if __name__ == "__main__":
main()
import socket
import struct
import time
for port in (11502, 11503, 11504, 1502):
try:
t0 = time.time()
s = socket.create_connection(("127.0.0.1", port), timeout=2)
# FC03 read addr=1 count=2
req = struct.pack(">HHHBBHH", 1, 0, 6, 1, 0x03, 1, 2)
s.sendall(req)
data = s.recv(256)
s.close()
print(f"port {port} OK dt={time.time()-t0:.3f}s resp={data.hex()}")
except Exception as e:
print(f"port {port} FAIL {e}")
$ErrorActionPreference = 'Continue'
Set-Location C:\jh-lab\gateway\win-protocol\build
Stop-Process -Name JhLabGateway -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Start-Process -FilePath .\JhLabGateway.exe -ArgumentList 'config.json' -WorkingDirectory (Get-Location).Path -WindowStyle Hidden
Start-Sleep -Seconds 4
Write-Host '=== PROCESS ==='
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Format-List Id, StartTime
Write-Host '=== NETSTAT ==='
netstat -ano | Select-String '38080'
Write-Host '=== LOCAL config/points ==='
try {
$r = Invoke-WebRequest -Uri 'http://127.0.0.1:38080/config/points' -UseBasicParsing -TimeoutSec 5
Write-Host $r.Content
} catch {
Write-Host $_.Exception.Message
}
Write-Host '=== LOCAL snapshot head ==='
try {
$s = (Invoke-WebRequest -Uri 'http://127.0.0.1:38080/snapshot' -UseBasicParsing -TimeoutSec 8).Content
Write-Host ("snap_len=" + $s.Length)
Write-Host $s.Substring(0, [Math]::Min(500, $s.Length))
} catch {
Write-Host $_.Exception.Message
}
Write-Host '=== AFTER 6s ==='
Start-Sleep -Seconds 6
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Format-List Id
netstat -ano | Select-String '38080'
...@@ -30,12 +30,35 @@ struct PointCfg { ...@@ -30,12 +30,35 @@ struct PointCfg {
std::string itemId; std::string itemId;
std::string name; std::string name;
std::string gatewayId; std::string gatewayId;
std::string regType;
std::string rw = "r"; // r | w | rw;F5/F6 写控点为 w,不参与周期读
int unitId = 1; int unitId = 1;
int address = 0; int address = 0;
std::string dataType = "Short"; std::string dataType = "Short";
double scale = 0.1; double scale = 0.1;
double bias = 0; double bias = 0;
bool enabled = true; bool enabled = true;
bool readable() const {
// 电表/流量计/能量计测点名 → 只读(CSV 常误标 -F6,优先于 rw)
const std::string n = name + itemId;
if (n.find("电压") != std::string::npos || n.find("电流") != std::string::npos ||
n.find("功率") != std::string::npos || n.find("电能") != std::string::npos ||
n.find("电度量") != std::string::npos || n.find("流量") != std::string::npos ||
n.find("流速") != std::string::npos || n.find("热量") != std::string::npos ||
n.find("冷量") != std::string::npos) {
return true;
}
// 平台已分类:r/rw 参与采集;w 为写控
if (rw == "w") return false;
if (rw == "r" || rw == "rw") return true;
std::string t = regType;
for (char& c : t) c = static_cast<char>(::toupper(static_cast<unsigned char>(c)));
// 现场 CSV:0X...-F5 / 4X...-F6 / -F15 / -F16 → 写控,不参与周期读
if (t.find("-F16") != std::string::npos || t.find("-F15") != std::string::npos) return false;
if (t.find("-F6") != std::string::npos || t.find("-F5") != std::string::npos) return false;
return true;
}
}; };
struct PointValue { struct PointValue {
...@@ -370,10 +393,17 @@ std::vector<PointCfg> parsePointsPayload(const std::string& body) { ...@@ -370,10 +393,17 @@ std::vector<PointCfg> parsePointsPayload(const std::string& body) {
p.name = extractString(obj, "name"); p.name = extractString(obj, "name");
p.gatewayId = extractString(obj, "gatewayId"); p.gatewayId = extractString(obj, "gatewayId");
if (p.gatewayId.empty()) p.gatewayId = extractString(obj, "gateway_id"); if (p.gatewayId.empty()) p.gatewayId = extractString(obj, "gateway_id");
p.regType = extractString(obj, "regType");
if (p.regType.empty()) p.regType = extractString(obj, "reg_type");
p.rw = extractString(obj, "rw");
if (p.rw.empty()) p.rw = "r";
p.unitId = extractInt(obj, "modbusId", extractInt(obj, "modbus_id", 1)); p.unitId = extractInt(obj, "modbusId", extractInt(obj, "modbus_id", 1));
p.address = extractInt(obj, "modbusAddress", extractInt(obj, "modbus_address", 0)); p.address = extractInt(obj, "modbusAddress", extractInt(obj, "modbus_address", 0));
p.dataType = extractString(obj, "dataType"); p.dataType = extractString(obj, "dataType");
if (p.dataType.empty()) p.dataType = "Short"; if (p.dataType.empty()) p.dataType = "Short";
// 不可读(写控)时对齐 rw;计量测点 readable() 已强制为真
if (!p.readable()) p.rw = "w";
else if (p.rw == "w") p.rw = "r";
double scaleOverride = extractDouble(obj, "scaleOverride", NAN); double scaleOverride = extractDouble(obj, "scaleOverride", NAN);
if (std::isnan(scaleOverride)) scaleOverride = extractDouble(obj, "scale_override", NAN); if (std::isnan(scaleOverride)) scaleOverride = extractDouble(obj, "scale_override", NAN);
double scale = extractDouble(obj, "scale", 0); double scale = extractDouble(obj, "scale", 0);
...@@ -506,12 +536,18 @@ void pollOnce() { ...@@ -506,12 +536,18 @@ void pollOnce() {
for (const auto& h : hosts) { for (const auto& h : hosts) {
if (!h.enabled || h.host.empty()) continue; if (!h.enabled || h.host.empty()) continue;
std::vector<PointCfg> mine; std::vector<PointCfg> mine;
size_t skippedWrite = 0;
for (const auto& p : points) { for (const auto& p : points) {
if (!p.enabled) continue; if (!p.enabled) continue;
if (p.gatewayId == h.id || (p.gatewayId.empty() && hosts.size() == 1)) mine.push_back(p); if (!(p.gatewayId == h.id || (p.gatewayId.empty() && hosts.size() == 1))) continue;
if (!p.readable()) {
++skippedWrite;
continue;
}
mine.push_back(p);
} }
std::cout << "[gateway] poll host " << h.id << " " << h.host << ":" << h.port std::cout << "[gateway] poll host " << h.id << " " << h.host << ":" << h.port
<< " points=" << mine.size() << "\n" << " points=" << mine.size() << " skipWrite=" << skippedWrite << "\n"
<< std::flush; << std::flush;
bool anyOk = false; bool anyOk = false;
int lastLat = 0; int lastLat = 0;
...@@ -829,6 +865,7 @@ std::string buildSnapshotJson(bool goodOnly) { ...@@ -829,6 +865,7 @@ std::string buildSnapshotJson(bool goodOnly) {
bool first = true; bool first = true;
for (const auto& p : g.points) { for (const auto& p : g.points) {
if (!p.enabled) continue; if (!p.enabled) continue;
if (!p.readable()) continue; // F5/F6 写控点不进采集快照
const std::string key = p.id.empty() ? p.itemId : p.id; const std::string key = p.id.empty() ? p.itemId : p.id;
auto it = g.values.find(key); auto it = g.values.find(key);
double raw = 0; double raw = 0;
...@@ -951,9 +988,92 @@ int main(int argc, char** argv) { ...@@ -951,9 +988,92 @@ int main(int argc, char** argv) {
res.set_content(o.str(), "application/json; charset=utf-8"); res.set_content(o.str(), "application/json; charset=utf-8");
}); });
svr.Post("/modbus/write", [](const httplib::Request& req, httplib::Response& res) { svr.Post("/modbus/write", [](const httplib::Request& req, httplib::Response& res) {
std::cout << "[gateway] modbus write: " << req.body << "\n"; std::cout << "[gateway] modbus write: " << req.body << "\n" << std::flush;
res.set_content("{\"ok\":true,\"message\":\"accepted (scaffold)\"}", const std::string pointId = extractString(req.body, "pointId");
const std::string itemId = extractString(req.body, "itemId");
double value = extractDouble(req.body, "value", NAN);
if (std::isnan(value)) {
res.status = 400;
res.set_content("{\"ok\":false,\"message\":\"缺少 value\"}", "application/json; charset=utf-8");
return;
}
PointCfg point;
ModbusHost host;
bool found = false;
{
std::lock_guard<std::mutex> lock(g.mu);
for (const auto& p : g.points) {
if ((!pointId.empty() && p.id == pointId) || (!itemId.empty() && p.itemId == itemId)) {
point = p;
found = true;
break;
}
}
if (found) {
for (const auto& h : g.modbusHosts) {
if (h.id == point.gatewayId) {
host = h;
break;
}
}
}
}
if (!found) {
res.status = 404;
res.set_content("{\"ok\":false,\"message\":\"点位不存在\"}", "application/json; charset=utf-8");
return;
}
if (point.readable() && point.rw == "r") {
res.status = 400;
res.set_content("{\"ok\":false,\"message\":\"该点位为只读采集点,不可写\"}",
"application/json; charset=utf-8"); "application/json; charset=utf-8");
return;
}
if (host.host.empty()) {
res.status = 400;
res.set_content("{\"ok\":false,\"message\":\"点位未绑定可用 Modbus 主机\"}",
"application/json; charset=utf-8");
return;
}
std::string reg = point.regType;
for (char& c : reg) c = static_cast<char>(::toupper(static_cast<unsigned char>(c)));
const bool isCoil = reg.find("0X") != std::string::npos || reg.find("-F5") != std::string::npos ||
reg.find("COIL") != std::string::npos;
const bool localhost =
host.host == "127.0.0.1" || host.host == "localhost" || host.host == "::1";
bool ok = false;
std::string detail;
if (localhost) {
// 本机联调:接受写入并记入值缓存,便于 UI 回读
ok = true;
detail = "accepted (builtin sim)";
PointValue v;
v.eng = value;
v.raw = (point.scale != 0) ? (value - point.bias) / point.scale : value;
v.quality = "good";
v.ts = isoNow();
std::lock_guard<std::mutex> lock(g.mu);
g.values[point.id.empty() ? point.itemId : point.id] = v;
} else if (isCoil) {
ok = modbus_tcp::writeSingleCoil(host.host, host.port, point.unitId, point.address, value != 0);
detail = ok ? "FC05 ok" : "FC05 failed";
} else {
double scale = point.scale != 0 ? point.scale : 1.0;
double raw = (value - point.bias) / scale;
auto regVal = static_cast<uint16_t>(static_cast<int32_t>(std::lround(raw)) & 0xFFFF);
ok = modbus_tcp::writeSingleRegister(host.host, host.port, point.unitId, point.address, regVal);
detail = ok ? "FC06 ok" : "FC06 failed";
}
std::ostringstream o;
o << "{\"ok\":" << (ok ? "true" : "false") << ",\"message\":\"" << jsonEscape(detail)
<< "\",\"pointId\":\"" << jsonEscape(point.id) << "\",\"itemId\":\"" << jsonEscape(point.itemId)
<< "\",\"address\":" << point.address << ",\"value\":" << value << "}";
if (!ok) res.status = 502;
res.set_content(o.str(), "application/json; charset=utf-8");
}); });
/** /**
......
...@@ -164,4 +164,108 @@ inline double regsToDouble(uint16_t a, uint16_t b, uint16_t c, uint16_t d) { ...@@ -164,4 +164,108 @@ inline double regsToDouble(uint16_t a, uint16_t b, uint16_t c, uint16_t d) {
inline int16_t asI16(uint16_t v) { return static_cast<int16_t>(v); } inline int16_t asI16(uint16_t v) { return static_cast<int16_t>(v); }
#ifdef _WIN32
inline bool txnExchange(SOCKET sock, const uint8_t* req, int reqLen, std::vector<uint8_t>& bodyOut) {
int sent = send(sock, reinterpret_cast<const char*>(req), reqLen, 0);
if (sent != reqLen) 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) return false;
got += n;
}
uint16_t len = rdBe16(hdr + 4);
if (len < 2 || len > 260) return false;
bodyOut.assign(len, 0);
bodyOut[0] = hdr[6];
int need = static_cast<int>(len) - 1;
got = 0;
while (got < need) {
int n = recv(sock, reinterpret_cast<char*>(bodyOut.data() + 1 + got), need - got, 0);
if (n <= 0) return false;
got += n;
}
return bodyOut.size() >= 2 && (bodyOut[1] & 0x80) == 0;
}
inline SOCKET connectTcp(const std::string& host, int port) {
if (!ensureWsa()) return INVALID_SOCKET;
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) return INVALID_SOCKET;
DWORD timeout = 1200;
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 INVALID_SOCKET;
}
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 INVALID_SOCKET;
}
return sock;
}
#endif
/** FC05 写单线圈;value=true→0xFF00 */
inline bool writeSingleCoil(const std::string& host, int port, int unitId, int address, bool value) {
#ifdef _WIN32
SOCKET sock = connectTcp(host, port);
if (sock == INVALID_SOCKET) return false;
static uint16_t txn = 1;
uint8_t req[12];
wrBe16(req + 0, txn++);
wrBe16(req + 2, 0);
wrBe16(req + 4, 6);
req[6] = static_cast<uint8_t>(unitId & 0xFF);
req[7] = 0x05;
wrBe16(req + 8, static_cast<uint16_t>(address));
wrBe16(req + 10, value ? 0xFF00 : 0x0000);
std::vector<uint8_t> body;
bool ok = txnExchange(sock, req, 12, body) && body.size() >= 2 && body[1] == 0x05;
closesocket(sock);
return ok;
#else
(void)host; (void)port; (void)unitId; (void)address; (void)value;
return false;
#endif
}
/** FC06 写单寄存器 */
inline bool writeSingleRegister(const std::string& host, int port, int unitId, int address,
uint16_t value) {
#ifdef _WIN32
SOCKET sock = connectTcp(host, port);
if (sock == INVALID_SOCKET) return false;
static uint16_t txn = 1;
uint8_t req[12];
wrBe16(req + 0, txn++);
wrBe16(req + 2, 0);
wrBe16(req + 4, 6);
req[6] = static_cast<uint8_t>(unitId & 0xFF);
req[7] = 0x06;
wrBe16(req + 8, static_cast<uint16_t>(address));
wrBe16(req + 10, value);
std::vector<uint8_t> body;
bool ok = txnExchange(sock, req, 12, body) && body.size() >= 2 && body[1] == 0x06;
closesocket(sock);
return ok;
#else
(void)host; (void)port; (void)unitId; (void)address; (void)value;
return false;
#endif
}
} // namespace modbus_tcp } // namespace modbus_tcp
import type { PointRw } from './types.js'
export type PointRwContext = {
name?: string
itemId?: string
/** 来源 CSV 文件名,如 `19216810103-E电电表.csv` */
sourceCsv?: string
category?: string
}
/**
* 计量仪表测点:现场 CSV 常把 Holding 一律标成 `-F6`,但电表/水表(流量计)/能量计
* 点名均为电压/电流/流量/热量等采集量,Description 为空,无写控说明 → 强制只读。
*/
export function isMeterTelemetryPoint(ctx: PointRwContext = {}): boolean {
const file = `${ctx.sourceCsv || ''} ${ctx.category || ''}`
const name = `${ctx.name || ''} ${ctx.itemId || ''}`
// 专用仪表点表(文件名),整表视为采集
if (/电表|能量计|流量计/.test(file)) return true
// 测点名明显为计量量(即使挂在其它 CSV)
if (
/电压|电流|有功|无功|功率因素|功率因数|电度量|电能|电量|流速|瞬时流量|累积流量|净累积|瞬时热量|累积热量|冷量/.test(
name,
)
) {
// 排除同名里偶发的控制词
if (/设定|启停|复位|清零命令|开关命令/.test(name)) return false
return true
}
return false
}
/**
* 根据现场 RegType 推断读写属性(对齐 Modbus 功能码习惯):
* - F5 / 写单线圈 → 控制点(写)
* - F6 / F16 / F15 → 写寄存器(控制点);但计量仪表测点例外,仍为读
* - 1X / 3X / Input → 只读采集
* - 其余 Holding/Coil 未标写功能码 → 默认可读(可再写时标 rw)
*/
export function inferPointRw(
regType: string,
fallback: PointRw = 'r',
ctx: PointRwContext = {},
): PointRw {
// 电表 / 流量计(水) / 能量计:业务上只采集,忽略 CSV 上的 -F6/-F16 误标
if (isMeterTelemetryPoint(ctx)) return 'r'
const t = (regType || '').toUpperCase()
if (!t) return fallback
// 写功能码:F5 写线圈;F6 写单寄存器;F15/F16 写多线圈/多寄存器
if (/\bF5\b|-F5\b|WRITE\s*SINGLE\s*COIL|写.*线圈/.test(t)) return 'w'
if (/\bF6\b|-F6\b|\bF16\b|-F16\b|\bF15\b|-F15\b|WRITE\s*(SINGLE|MULTIPLE)\s*REGISTER|写.*寄存器/.test(t)) {
return 'w'
}
if (/1X|3X|INPUT\s*STATUS|INPUT\s*REGISTER|只读/.test(t)) return 'r'
if (/0X|4X|HOLDING|COIL/.test(t)) return 'rw'
return fallback
}
/** 是否参与周期采集(写控点不读;计量点即使库内 rw 误标也为可读) */
export function isReadablePoint(p: {
rw?: string | null
regType?: string | null
name?: string
itemId?: string
sourceCsv?: string
category?: string
}): boolean {
const ctx = {
name: p.name || '',
itemId: p.itemId || '',
sourceCsv: p.sourceCsv,
category: p.category,
}
if (isMeterTelemetryPoint(ctx)) return true
const rw = inferPointRw(p.regType || '', (p.rw as 'r' | 'w' | 'rw') || 'r', ctx).toLowerCase()
return rw !== 'w'
}
...@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto' ...@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'
import { config } from '../config.js' import { config } from '../config.js'
import { decodeCsvBuffer, parsePointsCsv, type ParsedPointRow } from './csv.js' import { decodeCsvBuffer, parsePointsCsv, type ParsedPointRow } from './csv.js'
import { defaultAlarmLimits } from './alarmDefaults.js' import { defaultAlarmLimits } from './alarmDefaults.js'
import { inferPointRw } from './pointRw.js'
import type { Device, DeviceCategory, DevicePoint, DevicesBundle, Gateway } from './types.js' import type { Device, DeviceCategory, DevicePoint, DevicesBundle, Gateway } from './types.js'
function now() { function now() {
...@@ -166,6 +167,7 @@ function pointFromRow( ...@@ -166,6 +167,7 @@ function pointFromRow(
gatewayId: string, gatewayId: string,
deviceId: string, deviceId: string,
t: string, t: string,
sourceCsv: string,
): DevicePoint { ): DevicePoint {
return { return {
id: randomUUID(), id: randomUUID(),
...@@ -189,12 +191,16 @@ function pointFromRow( ...@@ -189,12 +191,16 @@ function pointFromRow(
maxEngRange: row.maxEngRange, maxEngRange: row.maxEngRange,
itemId: row.itemId, itemId: row.itemId,
snapshotPath: '', snapshotPath: '',
rw: 'r', rw: inferPointRw(row.regType, 'r', {
energyKind: /电表|电能|电压|电流|功率|有功/.test(row.name) name: row.name,
itemId: row.itemId,
sourceCsv,
}),
energyKind: /电表|电能|电压|电流|功率|有功/.test(`${row.name} ${sourceCsv}`)
? 'power' ? 'power'
: /流量计|水/.test(row.name) : /流量计|水/.test(`${row.name} ${sourceCsv}`)
? 'water' ? 'water'
: /能量计|冷量/.test(row.name) : /能量计|冷量/.test(`${row.name} ${sourceCsv}`)
? 'cooling' ? 'cooling'
: null, : null,
scaleOverride: resolveScaleOverride(row), scaleOverride: resolveScaleOverride(row),
...@@ -266,7 +272,7 @@ export function buildBundleFromProtocolDir(dir: string): DevicesBundle { ...@@ -266,7 +272,7 @@ export function buildBundleFromProtocolDir(dir: string): DevicesBundle {
}) })
} }
for (const row of group.rows) { for (const row of group.rows) {
points.push(pointFromRow(row, gatewayId, deviceId, t)) points.push(pointFromRow(row, gatewayId, deviceId, t, fileBase))
} }
} }
} }
......
...@@ -4,6 +4,7 @@ import { config } from '../config.js' ...@@ -4,6 +4,7 @@ import { config } from '../config.js'
import { query } from '../db/pool.js' import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js' import { isPgEnabled } from '../db/state.js'
import { defaultAlarmLimits } from './alarmDefaults.js' import { defaultAlarmLimits } from './alarmDefaults.js'
import { inferPointRw, isReadablePoint } from './pointRw.js'
import { import {
decodeCsvBuffer, decodeCsvBuffer,
exportPointsCsv, exportPointsCsv,
...@@ -252,6 +253,31 @@ async function backfillAlarmLimitsPgOnce() { ...@@ -252,6 +253,31 @@ async function backfillAlarmLimitsPgOnce() {
if (n) console.log(`[devices] backfilled alarm limits for ${n}/${total} points`) if (n) console.log(`[devices] backfilled alarm limits for ${n}/${total} points`)
} }
/** 按 RegType 纠正 rw:F5/F6 等为写控点;电表/流量计/能量计强制只读 */
async function backfillPointRwPg() {
const points = await listPoints()
const devices = await listDevices()
const byId = new Map(devices.map((d) => [d.id, d]))
let n = 0
for (const p of points) {
const d = p.deviceId ? byId.get(p.deviceId) : undefined
const sourceCsv =
d?.meta && typeof (d.meta as { sourceCsv?: unknown }).sourceCsv === 'string'
? (d.meta as { sourceCsv: string }).sourceCsv
: ''
const inferred = inferPointRw(p.regType, p.rw || 'r', {
name: p.name,
itemId: p.itemId,
sourceCsv,
category: d?.category,
})
if (inferred === p.rw) continue
await updatePoint(p.id, { rw: inferred })
n += 1
}
if (n) console.log(`[devices] backfilled rw from regType for ${n} points`)
}
async function writeSeedPg(seed: DevicesBundle) { async function writeSeedPg(seed: DevicesBundle) {
for (const g of seed.gateways) { for (const g of seed.gateways) {
await query( await query(
...@@ -307,6 +333,7 @@ export async function ensureDevicesSeed() { ...@@ -307,6 +333,7 @@ export async function ensureDevicesSeed() {
) )
} else { } else {
await backfillAlarmLimitsPgOnce() await backfillAlarmLimitsPgOnce()
await backfillPointRwPg()
} }
return return
} }
...@@ -314,6 +341,22 @@ export async function ensureDevicesSeed() { ...@@ -314,6 +341,22 @@ export async function ensureDevicesSeed() {
const file = loadFile() const file = loadFile()
if (file && file.gateways.length && !force && !isStubFridgeOnly(file)) { if (file && file.gateways.length && !force && !isStubFridgeOnly(file)) {
mem = file mem = file
const byId = new Map(mem.devices.map((d) => [d.id, d]))
mem.points = mem.points.map((p) => {
const d = p.deviceId ? byId.get(p.deviceId) : undefined
const sourceCsv =
d?.meta && typeof (d.meta as { sourceCsv?: unknown }).sourceCsv === 'string'
? (d.meta as { sourceCsv: string }).sourceCsv
: ''
const inferred = inferPointRw(p.regType, p.rw || 'r', {
name: p.name,
itemId: p.itemId,
sourceCsv,
category: d?.category,
})
return inferred === p.rw ? p : { ...p, rw: inferred }
})
saveFile()
return return
} }
mem = seedBundle() mem = seedBundle()
...@@ -583,7 +626,7 @@ function pointFromParsed( ...@@ -583,7 +626,7 @@ function pointFromParsed(
maxEngRange: row.maxEngRange, maxEngRange: row.maxEngRange,
itemId: row.itemId, itemId: row.itemId,
snapshotPath: '', snapshotPath: '',
rw: 'r', rw: inferPointRw(row.regType, 'r', { name: row.name, itemId: row.itemId }),
energyKind: null, energyKind: null,
scaleOverride: scaleOverride:
row.scale !== 0 row.scale !== 0
...@@ -628,7 +671,12 @@ export async function createPoint( ...@@ -628,7 +671,12 @@ export async function createPoint(
maxEngRange: input.maxEngRange ?? 0, maxEngRange: input.maxEngRange ?? 0,
itemId: input.itemId || `${input.name}@${input.modbusAddress}`, itemId: input.itemId || `${input.name}@${input.modbusAddress}`,
snapshotPath: input.snapshotPath || '', snapshotPath: input.snapshotPath || '',
rw: input.rw || 'r', rw:
input.rw ||
inferPointRw(input.regType || '', 'r', {
name: input.name,
itemId: input.itemId || '',
}),
energyKind: input.energyKind ?? null, energyKind: input.energyKind ?? null,
scaleOverride: input.scaleOverride ?? null, scaleOverride: input.scaleOverride ?? null,
alarmLow: alarmLow:
...@@ -784,6 +832,7 @@ export async function getBundleForGateway(): Promise<{ ...@@ -784,6 +832,7 @@ export async function getBundleForGateway(): Promise<{
return { return {
pointsVersion, pointsVersion,
gateways: gateways.filter((g) => g.enabled && g.kind === 'modbus_host'), gateways: gateways.filter((g) => g.enabled && g.kind === 'modbus_host'),
// 下发全量启用点(含写控);网关侧按 rw/regType 决定读或写
points: points.filter((p) => p.enabled), points: points.filter((p) => p.enabled),
} }
} }
...@@ -871,7 +920,7 @@ export async function applyPointTelemetry(samples: PointTelemetrySample[]): Prom ...@@ -871,7 +920,7 @@ export async function applyPointTelemetry(samples: PointTelemetrySample[]): Prom
/** Mock/网关旁路:按点位生成可读工程值,便于后台联调 */ /** Mock/网关旁路:按点位生成可读工程值,便于后台联调 */
export async function applyMockPointTelemetry(): Promise<number> { export async function applyMockPointTelemetry(): Promise<number> {
const points = (await listPoints()).filter((p) => p.enabled) const points = (await listPoints()).filter((p) => p.enabled && isReadablePoint(p))
if (!points.length) return 0 if (!points.length) return 0
const t = Date.now() const t = Date.now()
const samples: PointTelemetrySample[] = points.map((p, i) => { const samples: PointTelemetrySample[] = points.map((p, i) => {
......
...@@ -17,7 +17,9 @@ import { ...@@ -17,7 +17,9 @@ import {
updatePoint, updatePoint,
} from '../devices/store.js' } from '../devices/store.js'
import type { DeviceCategory } from '../devices/types.js' import type { DeviceCategory } from '../devices/types.js'
import { fetchGatewayHealth, syncPointsToGateway } from '../gateway/client.js' import { fetchGatewayHealth, gatewayModbusWrite, syncPointsToGateway } from '../gateway/client.js'
import { applyWriteLog } from '../realtime/store.js'
import { inferPointRw } from '../devices/pointRw.js'
export const devicesRouter = Router() export const devicesRouter = Router()
...@@ -185,3 +187,50 @@ devicesRouter.get('/points/export', authRequired, async (req, res) => { ...@@ -185,3 +187,50 @@ devicesRouter.get('/points/export', authRequired, async (req, res) => {
devicesRouter.post('/points/sync-gateway', authRequired, requireRole('admin'), async (_req, res) => { devicesRouter.post('/points/sync-gateway', authRequired, requireRole('admin'), async (_req, res) => {
res.json(await syncPointsToGateway()) res.json(await syncPointsToGateway())
}) })
/** 向可写点位下发 Modbus 写(F5 线圈 / F6 寄存器) */
devicesRouter.post('/points/:id/write', authRequired, requireRole('admin'), async (req, res) => {
try {
const points = await listPoints()
const point = points.find((p) => p.id === req.params.id)
if (!point) {
res.status(404).json({ message: '点位不存在' })
return
}
const rw = inferPointRw(point.regType, point.rw || 'r', {
name: point.name,
itemId: point.itemId,
})
if (rw !== 'w' && rw !== 'rw') {
res.status(400).json({ message: '该点位为只读采集点,不可写' })
return
}
const value = Number(req.body?.value)
if (!Number.isFinite(value)) {
res.status(400).json({ message: '请提供数值 value' })
return
}
const result = await gatewayModbusWrite({
pointId: point.id,
itemId: point.itemId,
value,
})
applyWriteLog('modbus_write', {
pointId: point.id,
itemId: point.itemId,
name: point.name,
regType: point.regType,
address: point.modbusAddress,
value,
by: req.user!.username,
gateway: result,
})
if (!result.ok) {
res.status(502).json({ ok: false, message: result.message })
return
}
res.json({ ok: true, message: result.message, pointId: point.id, value })
} catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '写入失败' })
}
})
...@@ -109,6 +109,7 @@ videoRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => { ...@@ -109,6 +109,7 @@ videoRouter.post('/devices', authRequired, requireRole('admin'), (req, res) => {
enabled: body.enabled, enabled: body.enabled,
ip: body.ip, ip: body.ip,
remark: body.remark, remark: body.remark,
scenePoint: body.scenePoint,
}) })
res.status(201).json({ device }) res.status(201).json({ device })
} catch (e) { } catch (e) {
...@@ -128,6 +129,9 @@ videoRouter.patch('/devices/:id', authRequired, requireRole('admin'), (req, res) ...@@ -128,6 +129,9 @@ videoRouter.patch('/devices/:id', authRequired, requireRole('admin'), (req, res)
enabled: body.enabled, enabled: body.enabled,
ip: body.ip, ip: body.ip,
remark: body.remark, remark: body.remark,
...(Object.prototype.hasOwnProperty.call(body, 'scenePoint')
? { scenePoint: body.scenePoint }
: {}),
}) })
res.json({ device }) res.json({ device })
} catch (e) { } catch (e) {
......
...@@ -9,6 +9,15 @@ export type DeviceType = 'ipc' | 'nvr-ch' ...@@ -9,6 +9,15 @@ export type DeviceType = 'ipc' | 'nvr-ch'
export type VideoProtocol = 'sdk' | 'rtsp' | 'isapi' export type VideoProtocol = 'sdk' | 'rtsp' | 'isapi'
export type StreamType = 'main' | 'sub' export type StreamType = 'main' | 'sub'
/** 三维场景打点(RTEngine Marker:x=经度 lng,y=纬度 lat,z=高度;icon 为引擎图标文件名) */
export interface ScenePoint {
x: number
y: number
z: number
/** 如 cctv-blue.png,对应引擎 /api/icons 列表 */
icon?: string
}
export interface VideoDevice { export interface VideoDevice {
id: string id: string
name: string name: string
...@@ -19,6 +28,8 @@ export interface VideoDevice { ...@@ -19,6 +28,8 @@ export interface VideoDevice {
enabled: boolean enabled: boolean
ip: string ip: string
remark: string remark: string
/** 三维场景坐标;未打点为 null */
scenePoint: ScenePoint | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
...@@ -83,6 +94,7 @@ function seedDevices(): VideoDevice[] { ...@@ -83,6 +94,7 @@ function seedDevices(): VideoDevice[] {
] ]
return rows.map((r) => ({ return rows.map((r) => ({
...r, ...r,
scenePoint: null as ScenePoint | null,
id: randomUUID(), id: randomUUID(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
...@@ -95,6 +107,37 @@ function normalizeProtocol(p: string | undefined): VideoProtocol { ...@@ -95,6 +107,37 @@ function normalizeProtocol(p: string | undefined): VideoProtocol {
return 'sdk' return 'sdk'
} }
function normalizeScenePoint(raw: unknown): ScenePoint | null {
if (!raw || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
const x = Number(o.x ?? o.lng ?? o.longitude)
const y = Number(o.y ?? o.lat ?? o.latitude)
const z = Number(o.z ?? o.height ?? o.alt ?? 0)
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
const icon = String(o.icon || o.markerIcon || '').trim()
const point: ScenePoint = { x, y, z: Number.isFinite(z) ? z : 0 }
if (icon) point.icon = icon.replace(/\\/g, '/').split('/').pop() || icon
return point
}
function normalizeDevice(raw: Partial<VideoDevice> & { id: string }): VideoDevice {
const now = new Date().toISOString()
return {
id: raw.id,
name: String(raw.name || ''),
channel: Number(raw.channel) || 1,
location: String(raw.location || ''),
deviceType: raw.deviceType === 'ipc' ? 'ipc' : 'nvr-ch',
status: raw.status === 'offline' || raw.status === 'alarm' ? raw.status : 'online',
enabled: raw.enabled !== false,
ip: String(raw.ip || ''),
remark: String(raw.remark || ''),
scenePoint: normalizeScenePoint(raw.scenePoint),
createdAt: raw.createdAt || now,
updatedAt: raw.updatedAt || now,
}
}
function normalizeData(raw: VideoData): VideoData { function normalizeData(raw: VideoData): VideoData {
const base = defaultConfig() const base = defaultConfig()
const c = { ...base, ...(raw.config || {}) } const c = { ...base, ...(raw.config || {}) }
...@@ -102,7 +145,10 @@ function normalizeData(raw: VideoData): VideoData { ...@@ -102,7 +145,10 @@ function normalizeData(raw: VideoData): VideoData {
c.protocol = normalizeProtocol(c.protocol as string) c.protocol = normalizeProtocol(c.protocol as string)
c.rtspPort = Number(c.rtspPort) || 554 c.rtspPort = Number(c.rtspPort) || 554
c.port = Number(c.port) || 8000 c.port = Number(c.port) || 8000
return { config: c, devices: Array.isArray(raw.devices) ? raw.devices : [] } const devices = Array.isArray(raw.devices)
? raw.devices.filter((d) => d && d.id).map((d) => normalizeDevice(d))
: []
return { config: c, devices }
} }
function ensureDir() { function ensureDir() {
...@@ -217,6 +263,7 @@ export function createDevice(input: { ...@@ -217,6 +263,7 @@ export function createDevice(input: {
enabled?: boolean enabled?: boolean
ip?: string ip?: string
remark?: string remark?: string
scenePoint?: ScenePoint | null
}): VideoDevice { }): VideoDevice {
const data = load() const data = load()
if (data.devices.some((d) => d.channel === input.channel)) { if (data.devices.some((d) => d.channel === input.channel)) {
...@@ -233,6 +280,7 @@ export function createDevice(input: { ...@@ -233,6 +280,7 @@ export function createDevice(input: {
enabled: input.enabled !== false, enabled: input.enabled !== false,
ip: (input.ip || '').trim(), ip: (input.ip || '').trim(),
remark: (input.remark || '').trim(), remark: (input.remark || '').trim(),
scenePoint: normalizeScenePoint(input.scenePoint),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
} }
...@@ -252,6 +300,7 @@ export function updateDevice( ...@@ -252,6 +300,7 @@ export function updateDevice(
enabled: boolean enabled: boolean
ip: string ip: string
remark: string remark: string
scenePoint: ScenePoint | null
}>, }>,
): VideoDevice { ): VideoDevice {
const data = load() const data = load()
...@@ -271,6 +320,10 @@ export function updateDevice( ...@@ -271,6 +320,10 @@ export function updateDevice(
if (patch.enabled !== undefined) data.devices[idx].enabled = Boolean(patch.enabled) if (patch.enabled !== undefined) data.devices[idx].enabled = Boolean(patch.enabled)
if (patch.ip !== undefined) data.devices[idx].ip = String(patch.ip).trim() if (patch.ip !== undefined) data.devices[idx].ip = String(patch.ip).trim()
if (patch.remark !== undefined) data.devices[idx].remark = String(patch.remark).trim() if (patch.remark !== undefined) data.devices[idx].remark = String(patch.remark).trim()
if (patch.scenePoint !== undefined) {
data.devices[idx].scenePoint =
patch.scenePoint === null ? null : normalizeScenePoint(patch.scenePoint)
}
data.devices[idx].updatedAt = new Date().toISOString() data.devices[idx].updatedAt = new Date().toISOString()
save(data) save(data)
return data.devices[idx] return data.devices[idx]
......
...@@ -12,8 +12,8 @@ WORKDIR /app/web ...@@ -12,8 +12,8 @@ WORKDIR /app/web
ARG VITE_API_BASE= ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=rtengine ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=http://192.168.32.121:6088/gis.html ARG VITE_RTENGINE_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
ARG VITE_UE_STREAM_URL=http://192.168.32.121:6088/gis.html ARG VITE_UE_STREAM_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
ENV VITE_API_BASE=$VITE_API_BASE \ ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \ VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_RTENGINE_URL=$VITE_RTENGINE_URL \ VITE_RTENGINE_URL=$VITE_RTENGINE_URL \
......
...@@ -24,6 +24,51 @@ server { ...@@ -24,6 +24,51 @@ server {
proxy_read_timeout 86400; proxy_read_timeout 86400;
} }
# RTEngineHD:同域 iframe,便于 postMessage;媒体仍由 ICE 直连引擎机
# 上游默认现场 121:6088(可按部署改)
location /rtengine/ {
proxy_pass http://192.168.32.121:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
location = /rtengine {
return 302 /rtengine/;
}
# 引擎 WebSocket 信令(dte.js 使用绝对路径 /peer-stream)
location /peer-stream {
proxy_pass http://192.168.32.121:6088/peer-stream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
# 场景书签 / embed API(gis.js 绝对路径 /designer/...)
location /designer/ {
proxy_pass http://192.168.32.121:6088/designer/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
......
...@@ -68,9 +68,12 @@ function alarmIcon(level: string) { ...@@ -68,9 +68,12 @@ function alarmIcon(level: string) {
</template> </template>
<style scoped> <style scoped>
/* 固定展示 3 条高度:每条 52px + 间距 8px → 3*52 + 2*8 = 172 */ /* 默认展示约 3 条;父级更高时可撑满 */
.alarm-viewport { .alarm-viewport {
height: 172px; flex: 1;
min-height: 120px;
max-height: 172px;
height: 100%;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 92%, transparent 100%); mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 92%, transparent 100%);
......
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRealtimeStore } from '@/stores/realtime' import { useRealtimeStore } from '@/stores/realtime'
import { PROTOCOL_ROOMS } from '@/data/rooms' import { SCREEN_AREAS } from '@/data/rooms'
import AppIcon from '@/components/icons/AppIcon.vue' import AppIcon from '@/components/icons/AppIcon.vue'
import CameraPlayDialog from '@/components/video/CameraPlayDialog.vue'
import { api } from '@/api/http'
import {
preloadRtengineIcons,
useRtengineBridge,
} from '@/composables/useRtengineBridge'
import type { VideoDevice } from '@/types/video'
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
areaLabel?: string areaLabel?: string
/** fill=铺满舞台底层;inset=嵌入面板 */
layout?: 'fill' | 'inset'
}>(), }>(),
{ areaLabel: '' }, { areaLabel: '', layout: 'inset' },
) )
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
type ViewportMode = 'glb' | 'ue-stream' | 'rtengine' type ViewportMode = 'glb' | 'ue-stream' | 'rtengine'
const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'rtengine') as ViewportMode const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'rtengine') as ViewportMode
/** RTEngineHD / UE 云渲染页面地址(iframe) */ /**
* RTEngineHD 云渲染地址(默认同域 /rtengine 反代,便于 postMessage)。
* 信令走 /peer-stream、书签走 /designer(见 vite/nginx 代理);
* WebRTC 媒体仍由 ICE 直连引擎机,一般不黑屏。
*/
const cloudUrl = const cloudUrl =
(import.meta.env.VITE_RTENGINE_URL || '').trim() || (import.meta.env.VITE_RTENGINE_URL || '').trim() ||
(import.meta.env.VITE_UE_STREAM_URL || '').trim() || (import.meta.env.VITE_UE_STREAM_URL || '').trim() ||
'http://192.168.32.121:6088/gis.html' '/rtengine/gis.html?scene=mvg8x4b453b45f38'
const useCloudFrame = computed( const useCloudFrame = computed(
() => () =>
...@@ -27,6 +40,95 @@ const useCloudFrame = computed( ...@@ -27,6 +40,95 @@ const useCloudFrame = computed(
) )
const viewerRef = ref<HTMLElement | null>(null) const viewerRef = ref<HTMLElement | null>(null)
const cloudFrameRef = ref<HTMLIFrameElement | null>(null)
const activeAreaId = ref<string>(SCREEN_AREAS[0]?.id || 'seq')
const cameras = ref<VideoDevice[]>([])
const playOpen = ref(false)
const playCam = ref<VideoDevice | null>(null)
let markersSynced = false
let engineBootstrapping = false
function cameraMarkerList() {
return cameras.value
.filter((d) => d.enabled && d.scenePoint)
.map((d) => ({
id: d.id,
name: d.name,
text: d.name,
x: d.scenePoint!.x,
y: d.scenePoint!.y,
z: d.scenePoint!.z,
icon: d.scenePoint!.icon || 'cctv-blue.png',
tag0: 'camera',
tag1: d.id,
}))
}
/** 引擎就绪后:预加载全部预设图标 → 显示场景 POI → 打上摄像头点位 */
async function onEngineReady() {
if (!useCloudFrame.value || engineBootstrapping) return
engineBootstrapping = true
try {
// 1) 加载引擎全部预设图标资源
await preloadRtengineIcons(undefined, cloudUrl)
// 2) 显示场景内已有 POI/图标
bridge.showScenePresets()
// 3) 确保摄像头列表最新,再写入 Marker
await loadCameras()
syncCameraMarkers(true)
// 再补两次,避免引擎尚未吃下 addMarkers
window.setTimeout(() => syncCameraMarkers(true), 1200)
window.setTimeout(() => syncCameraMarkers(true), 3000)
} catch {
syncCameraMarkers(true)
} finally {
engineBootstrapping = false
}
}
function syncCameraMarkers(force = false) {
if (!useCloudFrame.value) return
if (markersSynced && !force) return
const list = cameraMarkerList()
if (!list.length) return
// 先按 id 覆盖写入(引擎侧 upsert)
for (const m of list) bridge.addMarker(m)
bridge.addMarkers(list)
markersSynced = true
}
const bridge = useRtengineBridge({
iframeRef: cloudFrameRef,
cloudUrl,
onReady: () => {
void onEngineReady()
},
onMouseClick: ({ markerId }) => {
if (!markerId) return
const cam =
cameras.value.find((d) => d.id === markerId) ||
cameras.value.find((d) => d.id === markerId.replace(/^cam:/, ''))
if (!cam) return
playCam.value = cam
playOpen.value = true
},
})
async function loadCameras() {
try {
const res = await api<{ devices: VideoDevice[] }>('/api/video/devices')
cameras.value = res.devices || []
markersSynced = false
} catch {
/* 大屏允许匿名;失败则无点位 */
}
}
function onCloudFrameLoad() {
// iframe 文档就绪后稍等信令/场景,再触发一次就绪管线
window.setTimeout(() => bridge.markReady(), 2500)
}
type SmoothControlsLike = { type SmoothControlsLike = {
onMouseDown?: (event: MouseEvent) => void onMouseDown?: (event: MouseEvent) => void
...@@ -80,14 +182,16 @@ function onViewerLoad() { ...@@ -80,14 +182,16 @@ function onViewerLoad() {
}) })
} }
/** 协议房间清单 + 实时测点;无快照时仍展示协议名称 */ /** 平面图主区域 + 实时测点(按 roomId 关联协议房间) */
const roomList = computed(() => { const areaList = computed(() => {
const live = realtime.snapshot.rooms const live = realtime.snapshot.rooms
return PROTOCOL_ROOMS.map((def) => { return SCREEN_AREAS.map((def) => {
const hit = live.find((r) => r.id === def.id) const hit = live.find((r) => r.id === def.roomId)
return { return {
id: def.id, id: def.id,
name: def.name, name: def.name,
viewpoint: def.viewpoint,
roomId: def.roomId,
temp: hit?.temp ?? null, temp: hit?.temp ?? null,
humidity: hit?.humidity ?? null, humidity: hit?.humidity ?? null,
pressure: hit?.pressure ?? null, pressure: hit?.pressure ?? null,
...@@ -97,12 +201,16 @@ const roomList = computed(() => { ...@@ -97,12 +201,16 @@ const roomList = computed(() => {
}) })
const selected = computed(() => { const selected = computed(() => {
return roomList.value.find((r) => r.id === realtime.selectedRoomId) || roomList.value[0] return (
areaList.value.find((r) => r.id === activeAreaId.value) ||
areaList.value.find((r) => r.roomId === realtime.selectedRoomId) ||
areaList.value[0]
)
}) })
const markers = computed(() => { const markers = computed(() => {
if (useCloudFrame.value) return [] if (useCloudFrame.value) return []
return roomList.value return areaList.value
.filter((r) => ['sample-1', 'lib-1', 'seq'].includes(r.id)) .filter((r) => ['sample-1', 'lib-1', 'seq'].includes(r.id))
.map((r, i) => ({ .map((r, i) => ({
id: r.id, id: r.id,
...@@ -121,12 +229,39 @@ const modeLabel = computed(() => { ...@@ -121,12 +229,39 @@ const modeLabel = computed(() => {
return '实景扫描模型 · 自由视角' return '实景扫描模型 · 自由视角'
}) })
/** 等价调用 iframe 内 $gisObj.flyToBookmark("测序区");书签名须与场景一致 */
function flyToViewpoint(name: string) {
if (!name) return
bridge.flyToBookmark(name)
}
function onSelectArea(areaId: string) {
const area = areaList.value.find((r) => r.id === areaId)
if (!area) return
activeAreaId.value = area.id
realtime.selectRoom(area.roomId)
if (useCloudFrame.value) flyToViewpoint(area.viewpoint)
}
function onSelect(id: string) { function onSelect(id: string) {
realtime.selectRoom(id) // 兼容 marker:id 可能是 areaId 或 roomId
const byArea = areaList.value.find((r) => r.id === id)
if (byArea) {
onSelectArea(byArea.id)
return
}
const byRoom = areaList.value.find((r) => r.roomId === id)
if (byRoom) onSelectArea(byRoom.id)
else realtime.selectRoom(id)
} }
onMounted(() => { onMounted(() => {
if (useCloudFrame.value) return void loadCameras()
if (useCloudFrame.value) {
// 兜底:部分环境下无 sceneEmbedLoaded,延时强制走就绪管线
window.setTimeout(() => bridge.markReady(), 6000)
return
}
const el = viewerRef.value const el = viewerRef.value
el?.addEventListener('load', onViewerLoad) el?.addEventListener('load', onViewerLoad)
applyMouseMapping() applyMouseMapping()
...@@ -138,32 +273,33 @@ onBeforeUnmount(() => { ...@@ -138,32 +273,33 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<div class="viewport"> <div class="viewport" :class="{ 'viewport--fill': props.layout === 'fill' }">
<div class="vp-corners"><i /><i /><i /><i /></div> <div v-if="props.layout !== 'fill'" class="vp-corners"><i /><i /><i /><i /></div>
<div class="vp-topbar"> <div v-if="props.layout !== 'fill'" class="vp-topbar">
<AppIcon name="pin" /> <AppIcon name="pin" />
<span class="area">{{ areaLabel || `当前区域:${selected?.name || '—'}` }}</span> <span class="area">{{ areaLabel || `当前区域:${selected?.name || '—'}` }}</span>
<span class="sep" /> <span class="sep" />
<span class="view">{{ modeLabel }}</span> <span class="view">{{ modeLabel }}</span>
</div> </div>
<div class="vp-room-panel"> <!-- 全屏模式下区域列表避开左右悬浮面板 -->
<div class="vp-room-panel" :class="{ 'vp-room-panel--fill': props.layout === 'fill' }">
<div class="vp-room-head"> <div class="vp-room-head">
<AppIcon name="pin" /> <AppIcon name="pin" />
<div> <div>
<div class="vp-room-label">房间 / 区域</div> <div class="vp-room-label">区域导航</div>
<div class="vp-room-sub">来自网关协议 · 共 {{ roomList.length }} 处</div> <div class="vp-room-sub">点击飞视点</div>
</div> </div>
</div> </div>
<div class="vp-room-list"> <div class="vp-room-list">
<button <button
v-for="r in roomList" v-for="r in areaList"
:key="r.id" :key="r.id"
type="button" type="button"
class="vp-room-item" class="vp-room-item"
:class="{ active: r.id === realtime.selectedRoomId, alarm: r.alarms.length > 0 }" :class="{ active: r.id === activeAreaId, alarm: r.alarms.length > 0 }"
@click="onSelect(r.id)" @click="onSelectArea(r.id)"
> >
<span class="name">{{ r.name }}</span> <span class="name">{{ r.name }}</span>
</button> </button>
...@@ -172,12 +308,14 @@ onBeforeUnmount(() => { ...@@ -172,12 +308,14 @@ onBeforeUnmount(() => {
<iframe <iframe
v-if="useCloudFrame" v-if="useCloudFrame"
ref="cloudFrameRef"
class="ue-frame" class="ue-frame"
:src="cloudUrl" :src="cloudUrl"
title="RTEngineHD Cloud Render" title="RTEngineHD Cloud Render"
allow="autoplay; fullscreen; microphone; camera; clipboard-read; clipboard-write; display-capture" allow="autoplay; fullscreen; microphone; camera; clipboard-read; clipboard-write; display-capture"
allowfullscreen allowfullscreen
referrerpolicy="no-referrer-when-downgrade" referrerpolicy="no-referrer-when-downgrade"
@load="onCloudFrameLoad"
/> />
<model-viewer <model-viewer
v-else v-else
...@@ -204,15 +342,22 @@ onBeforeUnmount(() => { ...@@ -204,15 +342,22 @@ onBeforeUnmount(() => {
<span>{{ m.label }}</span> <span>{{ m.label }}</span>
</div> </div>
<div class="vp-tip"> <div v-if="props.layout !== 'fill'" class="vp-tip">
<AppIcon name="hand" class-name="hand" /> <AppIcon name="hand" class-name="hand" />
<span v-if="useCloudFrame"> <span v-if="useCloudFrame">
RTEngineHD 云渲染 · 在画面内操作漫游 · 右侧点选房间 RTEngineHD 云渲染 · 在画面内操作漫游 · 右侧点选区域飞视点
</span> </span>
<span v-else> <span v-else>
<b>左键</b> 拖动 · <b>右键</b> 旋转 · <b>滚轮</b> 缩放 · 右侧点选房间 <b>左键</b> 拖动 · <b>右键</b> 旋转 · <b>滚轮</b> 缩放 · 右侧点选区域
</span> </span>
</div> </div>
<CameraPlayDialog
:open="playOpen"
:device-id="playCam?.id || ''"
:title="playCam?.name"
@close="playOpen = false"
/>
</div> </div>
</template> </template>
...@@ -223,6 +368,14 @@ onBeforeUnmount(() => { ...@@ -223,6 +368,14 @@ onBeforeUnmount(() => {
background: linear-gradient(180deg, #0a2148, #041022); background: linear-gradient(180deg, #0a2148, #041022);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.04), 0 14px 34px rgba(2,8,23,.5); box-shadow: inset 0 0 0 1px rgba(255,255,255,.04), 0 14px 34px rgba(2,8,23,.5);
} }
.viewport--fill {
width: 100%;
height: 100%;
border: none;
border-radius: 0;
box-shadow: none;
background: #000;
}
.scene, .ue-frame { .scene, .ue-frame {
position: absolute; inset: 0; width: 100%; height: 100%; border: 0; position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
background: #000; background: #000;
...@@ -241,24 +394,37 @@ onBeforeUnmount(() => { ...@@ -241,24 +394,37 @@ onBeforeUnmount(() => {
position: absolute; z-index: 6; position: absolute; z-index: 6;
right: 14px; top: 14px; right: 14px; top: 14px;
width: max-content; width: max-content;
max-width: 128px; min-width: 108px;
max-width: 148px;
max-height: calc(100% - 72px); max-height: calc(100% - 72px);
display: flex; flex-direction: column; display: flex; flex-direction: column;
border-radius: 6px; border-radius: 6px;
background: rgba(6, 18, 42, 0.42); background: rgba(6, 18, 42, 0.55);
border: 1px solid rgba(91, 212, 255, 0.22); border: 1px solid rgba(91, 212, 255, 0.28);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
overflow: hidden; overflow: hidden;
pointer-events: auto;
}
/* 夹在左右悬浮面板之间,避开顶栏 */
.vp-room-panel--fill {
right: 492px;
top: 148px;
max-height: calc(100% - 260px);
} }
.vp-room-head { .vp-room-head {
display: flex; align-items: center; justify-content: center; gap: 6px; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 6px 8px; padding: 7px 8px;
border-bottom: 1px solid rgba(62,200,255,.12); border-bottom: 1px solid rgba(62,200,255,.12);
} }
.vp-room-head :deep(.pin) { width: 12px; height: 12px; opacity: .85; } .vp-room-head :deep(.pin) { width: 12px; height: 12px; opacity: .85; }
.vp-room-head > div { text-align: center; } .vp-room-head > div { text-align: center; }
.vp-room-label { font-size: 10px; font-weight: 600; color: rgba(255,255,255,.9); } .vp-room-label { font-size: 11px; font-weight: 600; color: rgba(255,255,255,.92); }
.vp-room-sub { display: none; } .vp-room-sub {
display: block;
margin-top: 2px;
font-size: 9px;
color: rgba(185, 214, 240, 0.72);
}
.vp-room-list { .vp-room-list {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
......
<script setup lang="ts">
import { useId } from 'vue'
withDefaults(
defineProps<{
title: string
variant?: 'bar' | 'line'
}>(),
{ variant: 'bar' },
)
const fillId = `titleFill-${useId()}`
</script>
<template>
<div class="panel-box">
<div class="title-bar" :class="variant">
<template v-if="variant === 'line'">
<i class="plus-icon" />
<span class="title-text">{{ title }}</span>
<b class="hairline" />
</template>
<template v-else>
<div class="title-main">
<i class="title-icon" />
<span class="title-text">{{ title }}</span>
</div>
<div class="title-extra">
<slot name="extra" />
<span class="title-dots">
<i />
<i />
</span>
</div>
<svg class="title-frame" viewBox="0 0 460 32" preserveAspectRatio="none">
<defs>
<linearGradient :id="fillId" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#0b4d86" stop-opacity="0.72" />
<stop offset="55%" stop-color="#07325a" stop-opacity="0.38" />
<stop offset="100%" stop-color="#031528" stop-opacity="0.08" />
</linearGradient>
</defs>
<path d="M0 0 H460 V32 H0 Z" :fill="`url(#${fillId})`" />
<path d="M0 31.3 H460" fill="none" stroke="#00f7ff" stroke-width="1.4" />
</svg>
</template>
</div>
<div class="panel-body" :class="variant">
<slot />
</div>
</div>
</template>
<style scoped>
.panel-box {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: rgba(0, 18, 42, 0.42);
backdrop-filter: blur(8px);
border: 1px solid rgba(0, 247, 255, 0.12);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
}
.title-bar {
position: relative;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px 2px 10px;
}
.title-bar.line {
height: 28px;
justify-content: flex-start;
gap: 8px;
padding: 0 4px 0 6px;
}
.title-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
filter: drop-shadow(0 0 6px rgba(0, 247, 255, 0.18));
}
.title-main,
.title-extra {
position: relative;
z-index: 1;
display: flex;
align-items: center;
}
.title-main { gap: 8px; }
.title-icon {
width: 10px;
height: 10px;
border: 1px solid #00f7ff;
background:
linear-gradient(#00f7ff, #00f7ff) center / 4px 4px no-repeat,
rgba(0, 40, 72, 0.6);
box-shadow: 0 0 8px rgba(0, 247, 255, 0.7);
}
.plus-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
background:
linear-gradient(#00f7ff, #00f7ff) center / 12px 2px no-repeat,
linear-gradient(#00f7ff, #00f7ff) center / 2px 12px no-repeat;
box-shadow: 0 0 8px rgba(0, 247, 255, 0.7);
}
.hairline {
flex: 1;
height: 1px;
margin-left: 4px;
background: linear-gradient(
90deg,
rgba(0, 247, 255, 0.7) 0%,
rgba(0, 247, 255, 0.18) 42%,
transparent 46%,
transparent 52%,
rgba(0, 247, 255, 0.2) 56%,
rgba(0, 247, 255, 0.05) 100%
);
}
.title-text {
font-family: var(--title-font);
color: #f4feff;
font-size: 15px;
font-weight: 400;
letter-spacing: 1px;
text-shadow: 0 0 8px rgba(0, 247, 255, 0.35);
white-space: nowrap;
}
.title-extra { gap: 8px; }
.title-dots { display: flex; gap: 4px; }
.title-dots i {
width: 6px;
height: 6px;
background: #00f7ff;
box-shadow: 0 0 6px #00f7ff;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 8px 10px 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.panel-body.line { padding: 4px 8px 8px; }
</style>
<script setup lang="ts">
import headerBar from '@/assets/screen/header-bar.png'
defineProps<{
title?: string
}>()
</script>
<template>
<header class="screen-header-hy">
<img class="banner" :src="headerBar" alt="" />
<h1>{{ title || '分子实验室三维可视化管理平台' }}</h1>
<div class="slot-left">
<slot name="left" />
</div>
<div class="slot-right">
<slot name="right" />
</div>
</header>
</template>
<style scoped>
.screen-header-hy {
position: relative;
width: 1920px;
height: 84px;
flex-shrink: 0;
pointer-events: auto;
}
.banner {
display: block;
width: 1920px;
height: 84px;
}
h1 {
position: absolute;
left: 0;
right: 0;
top: 10px;
z-index: 1;
margin: 0;
height: 42px;
line-height: 42px;
text-align: center;
font-family: var(--title-font);
font-size: 30px;
font-weight: 900;
font-synthesis: style;
letter-spacing: 6px;
color: #f4feff;
font-style: italic;
text-shadow: 0 0 14px rgba(0, 200, 255, 0.45);
pointer-events: none;
}
.slot-left,
.slot-right {
position: absolute;
top: 0;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 12px;
pointer-events: auto;
}
.slot-left {
left: 24px;
}
.slot-right {
right: 24px;
}
</style>
<script setup lang="ts">
import WebRtcPlayer from '@/components/video/WebRtcPlayer.vue'
defineProps<{
open: boolean
deviceId: string
title?: string
}>()
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<div v-if="open" class="mask" @click.self="emit('close')">
<div class="dialog">
<div class="head">
<h3>{{ title || '视频预览' }}</h3>
<button class="btn ghost" type="button" @click="emit('close')">关闭</button>
</div>
<div class="body">
<WebRtcPlayer v-if="deviceId" :device-id="deviceId" />
</div>
</div>
</div>
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
z-index: 70;
background: rgba(0, 8, 20, 0.65);
display: grid;
place-items: center;
padding: 24px;
pointer-events: auto;
}
.dialog {
width: min(960px, 92vw);
background: #071426;
border: 1px solid rgba(0, 247, 255, 0.28);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(0, 247, 255, 0.16);
}
.head h3 {
margin: 0;
font-size: 16px;
color: #f4feff;
}
.body {
padding: 12px;
min-height: 360px;
background: #000;
}
</style>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import {
defaultRtengineUrl,
fetchRtengineIcons,
preloadRtengineIcons,
rtengineIconUrl,
useRtengineBridge,
type ScenePoint,
} from '@/composables/useRtengineBridge'
const props = defineProps<{
open: boolean
title?: string
initial?: ScenePoint | null
}>()
const emit = defineEmits<{
close: []
save: [point: ScenePoint]
}>()
const cloudUrl = defaultRtengineUrl()
const iframeRef = ref<HTMLIFrameElement | null>(null)
const picked = ref<ScenePoint | null>(props.initial ? { ...props.initial } : null)
const selectedIcon = ref(props.initial?.icon || 'cctv-blue.png')
const icons = ref<string[]>([])
const iconError = ref('')
const iconFilter = ref('')
const hint = ref('场景加载后,点击地面拾取摄像头位置')
const preferred = [
'cctv-blue.png',
'cctv-green.png',
'cctv-orange.png',
'cctv-red.png',
'cctv.png',
'ai-camera-blue.png',
'ai-camera-green.png',
'ai-camera-orange.png',
'ai-camera-red.png',
]
const filteredIcons = computed(() => {
const q = iconFilter.value.trim().toLowerCase()
let list = icons.value.slice()
list.sort((a, b) => {
const ia = preferred.indexOf(a)
const ib = preferred.indexOf(b)
if (ia >= 0 || ib >= 0) return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib)
return a.localeCompare(b)
})
if (q) list = list.filter((f) => f.toLowerCase().includes(q))
return list
})
const bridge = useRtengineBridge({
iframeRef,
cloudUrl,
onMouseClick: ({ point, markerId }) => {
if (point) {
picked.value = { ...point, icon: selectedIcon.value }
hint.value = markerId
? `已拾取(对象 ${markerId})`
: `已拾取 X=${point.x.toFixed(6)} Y=${point.y.toFixed(6)} Z=${point.z.toFixed(2)}`
}
},
onReady: () => {
hint.value = '引擎已就绪 · 正在加载预设图标…'
bridge.setPickMode(true)
bridge.showScenePresets()
void loadIcons(true)
},
})
async function loadIcons(fromEngine = false) {
iconError.value = ''
try {
icons.value = fromEngine
? await preloadRtengineIcons(undefined, cloudUrl)
: await fetchRtengineIcons(cloudUrl)
if (!icons.value.includes(selectedIcon.value) && icons.value.length) {
const cam = icons.value.find((f) => /cctv|camera/i.test(f))
selectedIcon.value = cam || icons.value[0]
}
if (fromEngine) hint.value = `已加载 ${icons.value.length} 个场景预设图标 · 点击地面拾取`
} catch (e) {
iconError.value = e instanceof Error ? e.message : '图标列表加载失败'
}
}
watch(
() => props.open,
(v) => {
if (v) {
picked.value = props.initial ? { ...props.initial } : null
selectedIcon.value = props.initial?.icon || 'cctv-blue.png'
hint.value = '场景加载后,点击地面拾取摄像头位置'
void loadIcons()
window.setTimeout(() => bridge.setPickMode(true), 1500)
} else {
bridge.setPickMode(false)
}
},
)
watch(selectedIcon, (icon) => {
if (picked.value) picked.value = { ...picked.value, icon }
})
onMounted(() => {
if (props.open) {
void loadIcons()
window.setTimeout(() => bridge.setPickMode(true), 1500)
}
})
onBeforeUnmount(() => bridge.setPickMode(false))
function confirm() {
if (!picked.value) {
hint.value = '请先在场景中点击拾取位置'
return
}
emit('save', { ...picked.value, icon: selectedIcon.value })
}
function clearPoint() {
picked.value = null
hint.value = '已清除,请重新点击拾取'
}
function iconSrc(file: string) {
return rtengineIconUrl(file, cloudUrl)
}
</script>
<template>
<div v-if="open" class="mask" @click.self="emit('close')">
<div class="dialog">
<div class="head">
<h3>{{ title || '三维场景打点' }}</h3>
<button class="btn ghost" type="button" @click="emit('close')">关闭</button>
</div>
<p class="hint">{{ hint }}</p>
<div class="body">
<aside class="icon-pane">
<div class="icon-head">
<span>引擎图标</span>
<input v-model="iconFilter" class="icon-search" placeholder="筛选…" />
</div>
<p v-if="iconError" class="icon-err">{{ iconError }}</p>
<div class="icon-grid">
<button
v-for="f in filteredIcons"
:key="f"
type="button"
class="icon-item"
:class="{ active: selectedIcon === f }"
:title="f"
@click="selectedIcon = f"
>
<img :src="iconSrc(f)" :alt="f" loading="lazy" />
<span>{{ f.replace(/\.png$/i, '') }}</span>
</button>
</div>
<div class="icon-current">
当前:
<img v-if="selectedIcon" :src="iconSrc(selectedIcon)" alt="" />
<b>{{ selectedIcon || '—' }}</b>
</div>
</aside>
<div class="frame-wrap">
<iframe
ref="iframeRef"
class="frame"
:src="cloudUrl"
title="RTEngine pick"
allow="autoplay; fullscreen; microphone; camera; clipboard-read; clipboard-write; display-capture"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"
/>
</div>
</div>
<div class="foot">
<div class="coords" v-if="picked">
X {{ picked.x.toFixed(6) }} · Y {{ picked.y.toFixed(6) }} · Z {{ picked.z.toFixed(2) }}
</div>
<div class="coords muted" v-else>尚未拾取</div>
<div class="actions">
<button class="btn ghost" type="button" @click="clearPoint">清除</button>
<button class="btn ghost" type="button" @click="emit('close')">取消</button>
<button class="btn" type="button" :disabled="!picked" @click="confirm">保存点位</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.mask {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(0, 8, 20, 0.72);
display: grid;
place-items: center;
padding: 24px;
}
.dialog {
width: min(1440px, 98vw);
height: min(860px, 94vh);
display: flex;
flex-direction: column;
background: #071426;
border: 1px solid rgba(0, 247, 255, 0.28);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(0, 247, 255, 0.16);
}
.head h3 {
margin: 0;
font-size: 16px;
color: #f4feff;
letter-spacing: 0.06em;
}
.hint {
margin: 0;
padding: 8px 16px;
font-size: 12px;
color: #7eb6d6;
background: rgba(0, 40, 72, 0.35);
}
.body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 280px 1fr;
}
.icon-pane {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid rgba(0, 247, 255, 0.14);
background: rgba(0, 16, 36, 0.65);
}
.icon-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
font-size: 12px;
color: #f4feff;
border-bottom: 1px solid rgba(0, 247, 255, 0.12);
}
.icon-search {
width: 120px;
height: 28px;
border-radius: 4px;
border: 1px solid rgba(0, 247, 255, 0.25);
background: rgba(0, 0, 0, 0.35);
color: #d7f4ff;
padding: 0 8px;
font-size: 12px;
}
.icon-err {
margin: 8px 12px 0;
font-size: 12px;
color: #ffb0b0;
}
.icon-grid {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
align-content: start;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px;
border-radius: 6px;
border: 1px solid rgba(0, 247, 255, 0.16);
background: rgba(8, 28, 56, 0.45);
color: #9ec9e0;
cursor: pointer;
font-size: 10px;
}
.icon-item img {
width: 36px;
height: 36px;
object-fit: contain;
}
.icon-item span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-item:hover {
border-color: rgba(0, 247, 255, 0.45);
}
.icon-item.active {
border-color: #00f7ff;
background: rgba(0, 100, 160, 0.35);
color: #fff;
box-shadow: 0 0 0 1px rgba(0, 247, 255, 0.25) inset;
}
.icon-current {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid rgba(0, 247, 255, 0.12);
font-size: 11px;
color: #7eb6d6;
}
.icon-current img {
width: 22px;
height: 22px;
object-fit: contain;
}
.icon-current b {
color: #f4feff;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.frame-wrap {
min-height: 0;
background: #000;
}
.frame {
width: 100%;
height: 100%;
border: 0;
display: block;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid rgba(0, 247, 255, 0.16);
}
.coords {
font-family: var(--digital-font);
font-size: 13px;
color: var(--accent);
}
.coords.muted {
color: var(--panel-muted);
}
.actions {
display: flex;
gap: 8px;
}
</style>
import { onBeforeUnmount, ref, type Ref } from 'vue'
export type ScenePoint = { x: number; y: number; z: number; icon?: string }
export type RtengineMarker = {
id: string
name: string
x: number
y: number
z: number
text?: string
showText?: boolean
type?: 'marker' | 'label'
tag0?: string
tag1?: string
scale?: number
/** 引擎图标文件名,如 cctv-blue.png */
icon?: string
}
/** 默认走同域 /rtengine 反代,避免 iframe postMessage 跨域 */
export const RTENGINE_SCENE_QUERY = 'scene=mvg8x4b453b45f38'
export const RTENGINE_PROXY_PATH = '/rtengine'
export function defaultRtengineUrl() {
return (
(import.meta.env.VITE_RTENGINE_URL || '').trim() ||
(import.meta.env.VITE_UE_STREAM_URL || '').trim() ||
`${RTENGINE_PROXY_PATH}/gis.html?${RTENGINE_SCENE_QUERY}`
)
}
export function rtengineOrigin(cloudUrl = defaultRtengineUrl()) {
try {
return new URL(cloudUrl, typeof window !== 'undefined' ? window.location.href : 'http://localhost').origin
} catch {
return typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
}
}
/**
* 引擎资源前缀:同域反代时为 /rtengine,直连 6088 时为空。
* 注意:不能把图标打到本站 /api(会撞业务 API)。
*/
export function rtengineAssetPrefix(cloudUrl = defaultRtengineUrl()) {
try {
const u = new URL(cloudUrl, typeof window !== 'undefined' ? window.location.href : 'http://localhost')
if (u.pathname.startsWith(`${RTENGINE_PROXY_PATH}/`) || u.pathname === RTENGINE_PROXY_PATH) {
return RTENGINE_PROXY_PATH
}
// 直连引擎机:资源挂在 origin 根路径
return ''
} catch {
return RTENGINE_PROXY_PATH
}
}
export function rtengineIconUrl(file: string, cloudUrl = defaultRtengineUrl()) {
const name = String(file || '').trim().replace(/\\/g, '/').split('/').pop() || ''
if (!name) return ''
const prefix = rtengineAssetPrefix(cloudUrl)
return `${rtengineOrigin(cloudUrl)}${prefix}/api/icons/${encodeURIComponent(name)}`
}
export async function fetchRtengineIcons(cloudUrl = defaultRtengineUrl()): Promise<string[]> {
const prefix = rtengineAssetPrefix(cloudUrl)
const res = await fetch(`${rtengineOrigin(cloudUrl)}${prefix}/api/icons`)
if (!res.ok) throw new Error(`图标列表加载失败 (${res.status})`)
const data = (await res.json()) as { files?: string[] } | string[]
const files = Array.isArray(data) ? data : data.files || []
return files.filter((f) => typeof f === 'string' && f.toLowerCase().endsWith('.png'))
}
/** 预加载引擎图标资源,确保 Marker 用到的贴图已进缓存 */
export async function preloadRtengineIcons(
files?: string[],
cloudUrl = defaultRtengineUrl(),
): Promise<string[]> {
const list = files?.length ? files : await fetchRtengineIcons(cloudUrl)
await Promise.all(
list.map(
(f) =>
new Promise<void>((resolve) => {
const img = new Image()
img.onload = () => resolve()
img.onerror = () => resolve()
img.src = rtengineIconUrl(f, cloudUrl)
}),
),
)
return list
}
function postToFrame(win: Window | null | undefined, cloudUrl: string, msg: unknown) {
if (!win) return
// 只发一次:同时 * 与精确 origin 会让 iframe 收到两条 message
let origin = '*'
try {
origin = new URL(cloudUrl, window.location.href).origin
} catch {
/* keep * */
}
try {
win.postMessage(msg, origin)
} catch {
try {
win.postMessage(msg, '*')
} catch {
/* ignore */
}
}
}
/** 从 RTEngine mouseClick / marker 回调里尽量解析坐标 */
export function parseScenePointFromMessage(data: unknown): ScenePoint | null {
if (!data || typeof data !== 'object') return null
const root = data as Record<string, unknown>
const candidates: unknown[] = [
root,
root.data,
root.location,
root.coord,
root.coordinates,
root.metaData,
]
if (typeof root.metaData === 'string') {
try {
candidates.push(JSON.parse(root.metaData))
} catch {
/* ignore */
}
}
for (const c of candidates) {
if (!c || typeof c !== 'object') continue
const o = c as Record<string, unknown>
const x = Number(o.x ?? o.lng ?? o.longitude ?? o.lon)
const y = Number(o.y ?? o.lat ?? o.latitude)
const z = Number(o.z ?? o.height ?? o.alt ?? o.altitude ?? 0)
if (Number.isFinite(x) && Number.isFinite(y)) {
return { x, y, z: Number.isFinite(z) ? z : 0 }
}
}
return null
}
export function parseMarkerIdFromMessage(data: unknown): string | null {
if (!data || typeof data !== 'object') return null
const root = data as Record<string, unknown>
const bags: Record<string, unknown>[] = [root]
if (root.data && typeof root.data === 'object') bags.push(root.data as Record<string, unknown>)
if (root.metaData && typeof root.metaData === 'object') {
bags.push(root.metaData as Record<string, unknown>)
}
if (typeof root.metaData === 'string') {
try {
bags.push(JSON.parse(root.metaData) as Record<string, unknown>)
} catch {
/* ignore */
}
}
for (const o of bags) {
const id = o.id ?? o.markerId ?? o.objectId ?? o.tag0 ?? o.tag1
if (id != null && String(id).trim()) return String(id).trim()
}
return null
}
type GisObj = {
flyToBookmark?: (id: unknown, options?: unknown, cb?: unknown) => unknown
flyToViewpoint?: (id: unknown, options?: unknown) => unknown
registerBookmarks?: (list: unknown) => unknown
whenBookmarksReady?: (cb?: () => void) => unknown
_bookmarkFlyReady?: boolean
[key: string]: unknown
}
export function useRtengineBridge(opts: {
iframeRef: Ref<HTMLIFrameElement | null>
cloudUrl: string
onMouseClick?: (payload: { point: ScenePoint | null; markerId: string | null; raw: unknown }) => void
onReady?: () => void
}) {
const ready = ref(false)
function win() {
return opts.iframeRef.value?.contentWindow ?? null
}
/** 同域反代时可直读 iframe.$gisObj;跨域会抛错,返回 null */
function gisObj(): GisObj | null {
try {
const w = win() as (Window & { $gisObj?: GisObj }) | null
if (!w) return null
void w.location.href
return w.$gisObj ?? null
} catch {
return null
}
}
/**
* 优先同域直调 $gisObj[type];否则 postMessage
* gis.js 监听:event.data = { type, data }
*/
function send(type: string, data?: unknown) {
const gis = gisObj()
const fn = gis?.[type]
if (typeof fn === 'function') {
try {
if (
type === 'flyToBookmark' ||
type === 'flyToViewpoint' ||
type === 'flyToMarker' ||
type === 'flyToHome'
) {
const id =
typeof data === 'string' || typeof data === 'number'
? data
: data && typeof data === 'object'
? (data as { id?: unknown; name?: unknown; bookmarkName?: unknown }).id ??
(data as { name?: unknown }).name ??
(data as { bookmarkName?: unknown }).bookmarkName ??
data
: data
;(fn as (a: unknown, b?: unknown) => unknown).call(gis, id, data)
return
}
;(fn as (a: unknown) => unknown).call(gis, data ?? {})
return
} catch {
/* fall through to postMessage */
}
}
postToFrame(win(), opts.cloudUrl, {
type,
data: data ?? {},
messageId: `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
})
}
const pendingFlies: string[] = []
let bookmarksEnsured = false
/** 从 /designer 拉取书签并 register,避免 iframe 内尚未 hydrate 时 fly 被排队吞掉 */
async function ensureBookmarks() {
if (bookmarksEnsured) return
const gis = gisObj()
if (!gis || typeof gis.registerBookmarks !== 'function') return
let sceneId = ''
try {
sceneId = new URL(opts.cloudUrl, window.location.href).searchParams.get('scene') || ''
} catch {
/* ignore */
}
if (!sceneId) return
const urls = [
`/designer/data/scenes/${encodeURIComponent(sceneId)}.json`,
`/designer/api/embed/scenes/${encodeURIComponent(sceneId)}`,
]
for (const url of urls) {
try {
const res = await fetch(url)
if (!res.ok) continue
const json = (await res.json()) as { bookmarks?: unknown[] }
if (!Array.isArray(json.bookmarks) || !json.bookmarks.length) continue
gis.registerBookmarks(json.bookmarks)
gis._bookmarkFlyReady = true
bookmarksEnsured = true
return
} catch {
/* try next */
}
}
}
/** 同域直调 $gisObj.flyToBookmark("测序区")(与引擎 console 一致) */
function flyToBookmark(name: string, options?: Record<string, unknown>) {
const id = String(name || '').trim()
if (!id) return
const tryDirect = (): boolean => {
const gis = gisObj()
if (!gis || typeof gis.flyToBookmark !== 'function') return false
try {
const ret = options ? gis.flyToBookmark(id, options) : gis.flyToBookmark(id)
if (ret && typeof ret === 'object' && (ret as { queued?: boolean }).queued) {
if (!pendingFlies.includes(id)) pendingFlies.push(id)
}
return true
} catch {
return false
}
}
if (tryDirect()) return
if (!pendingFlies.includes(id)) pendingFlies.push(id)
send('flyToBookmark', id)
void ensureBookmarks().then(() => {
tryDirect()
})
window.setTimeout(() => tryDirect(), 600)
window.setTimeout(() => tryDirect(), 2000)
}
function flushPendingFlies() {
const list = pendingFlies.splice(0, pendingFlies.length)
for (const id of list) flyToBookmark(id)
}
function setPickMode(enabled: boolean) {
send('setMapPickMode', { state: enabled, enable: enabled })
}
function markerPayload(m: RtengineMarker) {
const icon = (m.icon || '').trim()
return {
id: m.id,
name: m.name,
text: m.text ?? m.name,
showText: m.showText !== false,
type: m.type || 'marker',
x: m.x,
y: m.y,
z: m.z,
scale: m.scale ?? 1,
scalex: m.scale ?? 1,
scaley: m.scale ?? 1,
scalez: m.scale ?? 1,
tag0: m.tag0 || 'camera',
tag1: m.tag1 || m.id,
...(icon ? { icon, url: icon } : {}),
}
}
function addMarker(m: RtengineMarker) {
send('addMarker', markerPayload(m))
}
function addMarkers(list: RtengineMarker[]) {
send(
'addMarkers',
list.map((m) => markerPayload(m)),
)
}
function removeMarker(id: string) {
send('removeMarker', { id })
}
function clearMarkers() {
send('clearMarkers', {})
}
/** 显示场景内预设 POI / Marker */
function showScenePresets() {
send('showPOIs', { visible: true })
send('setPoiVisible', { visible: true })
send('setPoiGroupVisible', { visible: true })
send('setMarkerVisible', { visible: true })
}
let readyNotified = false
function markReady() {
if (readyNotified) {
void ensureBookmarks().then(() => flushPendingFlies())
return
}
readyNotified = true
ready.value = true
opts.onReady?.()
void ensureBookmarks().then(() => flushPendingFlies())
// 书签 / $gisObj 可能稍晚就绪,再冲几次排队飞视
window.setTimeout(() => {
void ensureBookmarks().then(() => flushPendingFlies())
}, 800)
window.setTimeout(() => flushPendingFlies(), 2500)
}
function onMessage(ev: MessageEvent) {
const msg = ev.data
if (!msg || typeof msg !== 'object') return
const type = String((msg as { type?: string }).type || '')
const data = (msg as { data?: unknown }).data ?? msg
const bag = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
// 场景书签已加载(scene-embed 上报)
if (
bag &&
(bag.bookmarksReady === true ||
(Array.isArray(bag.bookmarks) && bag.bookmarks.length > 0) ||
(typeof bag.bookmarkCount === 'number' && bag.bookmarkCount > 0))
) {
markReady()
flushPendingFlies()
}
if (
type === 'sceneEmbedLoaded' ||
type === 'sceneEmbedProgress' ||
type === 'ueConnected' ||
type === 'peerConnected' ||
type === 'psConnected' ||
type === 'streamPlaying' ||
type === 'bookmarksReady' ||
type === 'sceneBookmarks'
) {
if (type === 'sceneEmbedProgress') {
const pct = Number((bag as { percent?: number } | null)?.percent)
if (Number.isFinite(pct) && pct >= 100) markReady()
return
}
markReady()
return
}
if (
type === 'mouseClick' ||
type === 'mouseClickCallback' ||
type === 'modelClick' ||
type === 'modelClickCallback' ||
type === 'selectMarker' ||
(data && typeof data === 'object' && (data as { eventId?: string }).eventId === 'mouseClick')
) {
opts.onMouseClick?.({
point: parseScenePointFromMessage(data),
markerId: parseMarkerIdFromMessage(data),
raw: data,
})
}
}
window.addEventListener('message', onMessage)
onBeforeUnmount(() => window.removeEventListener('message', onMessage))
return {
ready,
send,
flyToBookmark,
setPickMode,
addMarker,
addMarkers,
removeMarker,
clearMarkers,
showScenePresets,
markReady,
flushPendingFlies,
}
}
...@@ -16,3 +16,22 @@ export const PROTOCOL_ROOMS = [ ...@@ -16,3 +16,22 @@ export const PROTOCOL_ROOMS = [
] as const ] as const
export type ProtocolRoomId = (typeof PROTOCOL_ROOMS)[number]['id'] export type ProtocolRoomId = (typeof PROTOCOL_ROOMS)[number]['id']
/**
* 大屏右侧区域导航(对齐 McgsPro 平面图主区域名)。
* `viewpoint` 须与三维场景书签/视点名称一致,点击后飞到该视点。
* `roomId` 用于联动实时测点选中。
*/
export const SCREEN_AREAS = [
{ id: 'seq', name: '测序区', viewpoint: '测序区', roomId: 'seq' as ProtocolRoomId },
{ id: 'lib-2', name: '文库制备区2', viewpoint: '文库制备区2', roomId: 'lib-2' as ProtocolRoomId },
{ id: 'lib-1', name: '文库制备区1', viewpoint: '文库制备区1', roomId: 'lib-1' as ProtocolRoomId },
{ id: 'sample-2', name: '样本制备区2', viewpoint: '样本制备区2', roomId: 'sample-2' as ProtocolRoomId },
{ id: 'sample-1', name: '样本制备区1', viewpoint: '样本制备区1', roomId: 'sample-1' as ProtocolRoomId },
{ id: 'consult', name: '咨询室', viewpoint: '咨询室', roomId: 'recv' as ProtocolRoomId },
{ id: 'prep', name: '准备区', viewpoint: '准备区', roomId: 'reagent' as ProtocolRoomId },
{ id: 'report', name: '报告室', viewpoint: '报告室', roomId: 'report' as ProtocolRoomId },
// 场景书签无「PCR走廊」,不放进导航,避免 flyToBookmark 无响应
] as const
export type ScreenAreaId = (typeof SCREEN_AREAS)[number]['id']
...@@ -11,6 +11,10 @@ const { time } = useClock() ...@@ -11,6 +11,10 @@ const { time } = useClock()
const ICON_PATHS: Record<string, string[]> = { const ICON_PATHS: Record<string, string[]> = {
devices: ['M3 4.2h12v9.6H3z', 'M6.2 15.4h5.6', 'M7.4 13.8v1.6', 'M10.6 13.8v1.6'], devices: ['M3 4.2h12v9.6H3z', 'M6.2 15.4h5.6', 'M7.4 13.8v1.6', 'M10.6 13.8v1.6'],
cameras: [
'M3.2 6.2h8.2l1.6-1.8h3.2v11.2H3.2z',
'M9.2 11.2a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4z',
],
users: [ users: [
'M6.6 8.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2z', 'M6.6 8.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2z',
'M12.2 8.4a2.1 2.1 0 1 0 0-4.2', 'M12.2 8.4a2.1 2.1 0 1 0 0-4.2',
...@@ -33,6 +37,13 @@ const navItems = computed(() => ...@@ -33,6 +37,13 @@ const navItems = computed(() =>
icon: 'devices', icon: 'devices',
}, },
{ {
to: '/admin/cameras',
label: '摄像头管理',
hint: '列表 / 三维打点',
tone: 'accent' as const,
icon: 'cameras',
},
{
to: '/admin/users', to: '/admin/users',
label: '用户管理', label: '用户管理',
hint: '账号 / 角色', hint: '账号 / 角色',
......
...@@ -6,7 +6,8 @@ import { useRealtimeStore } from '@/stores/realtime' ...@@ -6,7 +6,8 @@ import { useRealtimeStore } from '@/stores/realtime'
import { useClock } from '@/composables/useClock' import { useClock } from '@/composables/useClock'
import { useMacauWeather } from '@/composables/useMacauWeather' import { useMacauWeather } from '@/composables/useMacauWeather'
import { useStageScale } from '@/composables/useStageScale' import { useStageScale } from '@/composables/useStageScale'
import AppIcon from '@/components/icons/AppIcon.vue' import ScreenHeader from '@/components/screen/ScreenHeader.vue'
import SceneViewport from '@/components/scene/SceneViewport.vue'
const auth = useAuthStore() const auth = useAuthStore()
const realtime = useRealtimeStore() const realtime = useRealtimeStore()
...@@ -41,48 +42,45 @@ function goAdmin() { ...@@ -41,48 +42,45 @@ function goAdmin() {
</script> </script>
<template> <template>
<div class="stage" :style="style"> <div class="stage stage--immersive" :style="style">
<header class="screen-header"> <!-- 底层:RTEngine / 三维场景铺满 -->
<div class="brand"> <div class="scene-layer">
<AppIcon name="logo" class-name="logo" /> <SceneViewport layout="fill" />
<div class="brand-txt">
<div class="sys">实验室智慧管控中心</div>
<div class="en">LAB INTELLIGENT CONTROL CENTER</div>
</div>
</div>
<div class="title-group">
<div class="wing" aria-hidden="true">
<span class="wing-line" />
<span class="wing-chevron" />
<span class="wing-dot" />
</div>
<h1>分子实验室三维可视化管理平台</h1>
<div class="wing right" aria-hidden="true">
<span class="wing-dot" />
<span class="wing-chevron" />
<span class="wing-line" />
</div> </div>
<!-- 四周渐变遮罩:压暗边缘,中间留白给三维 -->
<div class="edge-mask" aria-hidden="true">
<i class="edge edge-t" />
<i class="edge edge-b" />
<i class="edge edge-l" />
<i class="edge edge-r" />
</div> </div>
<!-- 上层:标题 / 导航 / 业务面板悬浮 -->
<div class="ui-layer">
<ScreenHeader>
<template #left>
<div class="weather-wrap"> <div class="weather-wrap">
<span class="place">{{ place }}</span> <span class="place">{{ place }}</span>
<span class="temp">{{ temp }}</span> <span class="temp">{{ temp }}</span>
<span class="w-text">{{ weatherText }}</span> <span class="w-text">{{ weatherText }}</span>
<span class="humid">RH:{{ humidity }}</span> <span class="humid">RH:{{ humidity }}</span>
</div> </div>
</template>
<template #right>
<div class="clock-wrap"> <div class="clock-wrap">
<span class="time">{{ time }}</span> <span class="time">{{ time }}</span>
<span class="date">{{ date }}</span> <span class="date">{{ date }}</span>
</div> </div>
<div class="hdr-status">
<div class="alarm-badge" @click="router.push('/dashboard/overview')"> <div class="alarm-badge" @click="router.push('/dashboard/overview')">
<span class="dot red" /> <span class="dot red" />
<span>告警 {{ realtime.alarmCount }}</span> <span>告警 {{ realtime.alarmCount }}</span>
</div> </div>
<button class="tops-logout admin-entry" type="button" @click="goAdmin">管理</button> <button class="tops-logout admin-entry" type="button" @click="goAdmin">管理</button>
</div> </template>
</header> </ScreenHeader>
<nav class="screen-nav"> <nav class="screen-nav screen-nav--float">
<RouterLink v-for="item in navItems" :key="item.to" class="nav-chip" :to="item.to"> <RouterLink v-for="item in navItems" :key="item.to" class="nav-chip" :to="item.to">
{{ item.label }} {{ item.label }}
</RouterLink> </RouterLink>
...@@ -91,21 +89,143 @@ function goAdmin() { ...@@ -91,21 +89,143 @@ function goAdmin() {
<span class="dot" :class="{ red: !realtime.connected }" /> <span class="dot" :class="{ red: !realtime.connected }" />
<span> <span>
{{ realtime.connected ? '实时链路在线' : '实时链路断开' }} {{ realtime.connected ? '实时链路在线' : '实时链路断开' }}
· 设备 {{ realtime.snapshot.online.devicesOnline }}/{{ realtime.snapshot.online.devicesTotal }} · 设备 {{ realtime.snapshot.online.devicesOnline }}/{{
realtime.snapshot.online.devicesTotal
}}
</span> </span>
</div> </div>
</nav> </nav>
<main class="screen-main"> <main class="screen-main screen-main--float">
<RouterView /> <RouterView />
</main> </main>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.brand :deep(.logo) { .stage--immersive {
width: 34px; background: #00060f;
height: 34px; }
.stage--immersive::before {
display: none;
}
.scene-layer {
position: absolute;
inset: 0;
z-index: 0;
}
.edge-mask {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.edge {
position: absolute;
display: block;
pointer-events: none;
}
.edge-t {
left: 0;
right: 0;
top: 0;
height: 220px;
background: linear-gradient(
180deg,
rgba(0, 6, 18, 0.92) 0%,
rgba(0, 10, 28, 0.55) 42%,
rgba(0, 10, 28, 0) 100%
);
}
.edge-b {
left: 0;
right: 0;
bottom: 0;
height: 200px;
background: linear-gradient(
0deg,
rgba(0, 6, 18, 0.9) 0%,
rgba(0, 10, 28, 0.5) 48%,
rgba(0, 10, 28, 0) 100%
);
}
.edge-l {
left: 0;
top: 0;
bottom: 0;
width: 520px;
background: linear-gradient(
90deg,
rgba(0, 8, 22, 0.82) 0%,
rgba(0, 12, 32, 0.45) 38%,
rgba(0, 12, 32, 0) 100%
);
}
.edge-r {
right: 0;
top: 0;
bottom: 0;
width: 520px;
background: linear-gradient(
270deg,
rgba(0, 8, 22, 0.82) 0%,
rgba(0, 12, 32, 0.45) 38%,
rgba(0, 12, 32, 0) 100%
);
}
.ui-layer {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
pointer-events: none;
}
.screen-nav--float {
pointer-events: auto;
background: linear-gradient(180deg, rgba(0, 12, 28, 0.55), rgba(0, 12, 28, 0.12));
border-bottom: 1px solid rgba(0, 247, 255, 0.12);
}
.screen-main--float {
flex: 1;
min-height: 0;
padding: 8px 16px 14px;
/* 默认穿透到三维层;各页面自行给面板开 pointer-events */
pointer-events: none;
}
.weather-wrap,
.clock-wrap {
position: static;
transform: none;
display: flex;
align-items: baseline;
gap: 10px;
white-space: nowrap;
}
.weather-wrap .place,
.weather-wrap .temp {
font-size: 16px;
font-weight: 600;
font-family: var(--digital-font);
color: #7cf0c4;
line-height: 1;
}
.weather-wrap .w-text,
.weather-wrap .humid {
font-size: 12px;
color: #7cf0c4;
}
.clock-wrap .time {
font-size: 16px;
font-weight: 600;
font-family: var(--digital-font);
color: var(--accent);
text-shadow: 0 0 0.55em rgba(0, 247, 255, 0.45);
}
.clock-wrap .date {
font-size: 12px;
color: var(--panel-muted);
} }
.admin-entry { .admin-entry {
font-size: 12px !important; font-size: 12px !important;
......
...@@ -110,6 +110,12 @@ const router = createRouter({ ...@@ -110,6 +110,12 @@ const router = createRouter({
meta: { title: '设备管理', requiresAuth: true }, meta: { title: '设备管理', requiresAuth: true },
}, },
{ {
path: 'cameras',
name: 'cameras',
component: () => import('@/views/cameras/CamerasView.vue'),
meta: { title: '摄像头管理', requiresAuth: true },
},
{
path: 'users', path: 'users',
name: 'users', name: 'users',
component: () => import('@/views/users/UserManageView.vue'), component: () => import('@/views/users/UserManageView.vue'),
......
...@@ -6,26 +6,13 @@ ...@@ -6,26 +6,13 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background: background: #00060f;
radial-gradient(ellipse at 18% -8%, rgba(61, 190, 255, 0.14), transparent 48%),
radial-gradient(ellipse at 92% 108%, rgba(226, 91, 91, 0.08), transparent 42%),
linear-gradient(180deg, #0c1824 0%, #081018 55%, #04080f 100%);
color: var(--text); color: var(--text);
font-family: var(--body-font); font-family: var(--body-font);
} }
.stage::before { .stage::before {
content: ''; content: none;
position: absolute; }
inset: 0;
pointer-events: none;
z-index: 0;
background:
linear-gradient(rgba(61, 190, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(61, 190, 255, 0.04) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 90% 80% at 50% 40%, #000 25%, transparent 90%);
}
.stage > * { position: relative; z-index: 1; }
.screen-header { .screen-header {
position: relative; position: relative;
...@@ -314,7 +301,16 @@ ...@@ -314,7 +301,16 @@
box-shadow: 0 0 0 1px rgba(61, 190, 255, 0.65) inset; box-shadow: 0 0 0 1px rgba(61, 190, 255, 0.65) inset;
} }
.page-fill { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; width: 100%; } .page-fill {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
height: 100%;
pointer-events: auto;
}
.page-fill > .panel { min-height: 0; } .page-fill > .panel { min-height: 0; }
.stub-box { .stub-box {
flex: 1; display: grid; place-items: center; flex: 1; display: grid; place-items: center;
......
/** /**
* Visual tokens aligned with emsc-v2/apps/seat (@emsc/ui-theme seat.scss / screen.scss) * Visual tokens — 大屏对齐海油数字孪生标题/面板风格
*/ */
@font-face {
font-family: 'AlibabaPuHuiTi-3-115-Black';
src:
url('/fonts/AlibabaPuHuiTi-3-115-Black/AlibabaPuHuiTi-3-115-Black.woff2') format('woff2'),
url('/fonts/AlibabaPuHuiTi-3-115-Black/AlibabaPuHuiTi-3-115-Black.woff') format('woff'),
url('/fonts/AlibabaPuHuiTi-3-115-Black/AlibabaPuHuiTi-3-115-Black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}
:root { :root {
--screen-bg: #081018; --screen-bg: #000b1d;
--panel-bg: rgba(8, 16, 24, 0.92); --panel-bg: rgba(0, 18, 42, 0.55);
--panel-border: rgba(61, 190, 255, 0.28); --panel-border: rgba(0, 247, 255, 0.22);
--panel-title: #e8f4ff; --panel-title: #f4feff;
--panel-muted: #8ea3b8; --panel-muted: #7eb6d6;
--text: #d5e6f2; --text: #d7f4ff;
--accent: #3dbeff; --accent: #00f7ff;
--cyan: #3dbeff; --cyan: #00f7ff;
--alert: #e25b5b; --alert: #ff4d4f;
--ok: #3cba7a; --ok: #52c41a;
--warn: #e0a04a; --warn: #faad14;
--title-font: Rajdhani, 'PingFang SC', 'Microsoft YaHei', sans-serif; --title-font: 'AlibabaPuHuiTi-3-115-Black', 'Noto Sans SC', 'PingFang SC', sans-serif;
--body-font: Rajdhani, 'PingFang SC', 'Microsoft YaHei', sans-serif; --body-font: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--digital-font: 'Share Tech Mono', ui-monospace, monospace; --digital-font: 'Share Tech Mono', ui-monospace, monospace;
/* Backward-compatible aliases used by existing views */ /* Backward-compatible aliases used by existing views */
......
...@@ -4,6 +4,14 @@ export type DeviceType = 'ipc' | 'nvr-ch' ...@@ -4,6 +4,14 @@ export type DeviceType = 'ipc' | 'nvr-ch'
export type VideoProtocol = 'sdk' | 'rtsp' | 'isapi' export type VideoProtocol = 'sdk' | 'rtsp' | 'isapi'
export type StreamType = 'main' | 'sub' export type StreamType = 'main' | 'sub'
/** 三维场景打点(RTEngine:x=lng,y=lat,z=height;icon 为引擎图标名) */
export interface ScenePoint {
x: number
y: number
z: number
icon?: string
}
export interface VideoDevice { export interface VideoDevice {
id: string id: string
name: string name: string
...@@ -14,6 +22,7 @@ export interface VideoDevice { ...@@ -14,6 +22,7 @@ export interface VideoDevice {
enabled: boolean enabled: boolean
ip: string ip: string
remark: string remark: string
scenePoint?: ScenePoint | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
......
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { api } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
import ScenePickDialog from '@/components/video/ScenePickDialog.vue'
import CameraPlayDialog from '@/components/video/CameraPlayDialog.vue'
import type { DeviceStatus, DeviceType, ScenePoint, VideoDevice } from '@/types/video'
const auth = useAuthStore()
const loading = ref(false)
const message = ref('')
const error = ref('')
const devices = ref<VideoDevice[]>([])
const form = reactive({
name: '',
channel: 1,
location: '',
deviceType: 'nvr-ch' as DeviceType,
ip: '',
remark: '',
})
const pickOpen = ref(false)
const pickDevice = ref<VideoDevice | null>(null)
const playOpen = ref(false)
const playDevice = ref<VideoDevice | null>(null)
const statusLabel: Record<DeviceStatus, string> = {
online: '在线',
offline: '离线',
alarm: '报警',
}
const typeLabel: Record<DeviceType, string> = {
ipc: '网络摄像机',
'nvr-ch': 'NVR 通道',
}
const placedCount = computed(() => devices.value.filter((d) => d.scenePoint).length)
async function load() {
loading.value = true
error.value = ''
try {
const res = await api<{ devices: VideoDevice[] }>('/api/video/devices', { token: auth.token })
devices.value = res.devices
const maxCh = devices.value.reduce((m, d) => Math.max(m, d.channel), 0)
form.channel = maxCh + 1
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function createDevice() {
if (!auth.isAdmin) return
message.value = ''
error.value = ''
try {
await api('/api/video/devices', {
method: 'POST',
token: auth.token,
body: JSON.stringify({ ...form }),
})
message.value = '已新增摄像头'
form.name = ''
form.location = ''
form.ip = ''
form.remark = ''
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '创建失败'
}
}
async function toggleEnabled(d: VideoDevice) {
if (!auth.isAdmin) return
try {
await api(`/api/video/devices/${d.id}`, {
method: 'PATCH',
token: auth.token,
body: JSON.stringify({ enabled: !d.enabled }),
})
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '更新失败'
}
}
async function removeDevice(d: VideoDevice) {
if (!auth.isAdmin) return
if (!confirm(`确定删除摄像头「${d.name}」?`)) return
try {
await api(`/api/video/devices/${d.id}`, { method: 'DELETE', token: auth.token })
message.value = '已删除'
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
}
function openPick(d: VideoDevice) {
pickDevice.value = d
pickOpen.value = true
}
async function onPickSave(point: ScenePoint) {
if (!pickDevice.value) return
try {
await api(`/api/video/devices/${pickDevice.value.id}`, {
method: 'PATCH',
token: auth.token,
body: JSON.stringify({ scenePoint: point }),
})
message.value = `已保存「${pickDevice.value.name}」三维点位`
pickOpen.value = false
pickDevice.value = null
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '保存点位失败'
}
}
async function clearPoint(d: VideoDevice) {
if (!auth.isAdmin) return
try {
await api(`/api/video/devices/${d.id}`, {
method: 'PATCH',
token: auth.token,
body: JSON.stringify({ scenePoint: null }),
})
message.value = `已清除「${d.name}」点位`
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '清除失败'
}
}
function preview(d: VideoDevice) {
playDevice.value = d
playOpen.value = true
}
function fmtPoint(p: ScenePoint | null | undefined) {
if (!p) return '—'
const icon = p.icon ? ` · ${p.icon}` : ''
return `${p.x.toFixed(5)}, ${p.y.toFixed(5)}, ${p.z.toFixed(1)}${icon}`
}
onMounted(load)
</script>
<template>
<div class="page-fill cameras-page">
<section class="panel list-panel">
<div class="panel-head">
<div class="panel-title">
<div class="vbar" />
<h3>摄像头管理</h3>
<div class="badge">已打点 {{ placedCount }}/{{ devices.length }}</div>
</div>
<div class="head-actions">
<button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button>
</div>
</div>
<p v-if="message" class="msg ok">{{ message }}</p>
<p v-if="error" class="msg err">{{ error }}</p>
<div v-if="auth.isAdmin" class="create-row">
<div class="field"><label>名称</label><input v-model="form.name" placeholder="如:走廊东" /></div>
<div class="field">
<label>通道</label><input v-model.number="form.channel" type="number" min="1" max="256" />
</div>
<div class="field"><label>安装位置</label><input v-model="form.location" placeholder="楼层/区域" /></div>
<div class="field">
<label>类型</label>
<select v-model="form.deviceType">
<option value="nvr-ch">NVR 通道</option>
<option value="ipc">网络摄像机</option>
</select>
</div>
<div class="field"><label>IP(可选)</label><input v-model="form.ip" /></div>
<button class="btn" type="button" @click="createDevice">新增摄像头</button>
</div>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>通道</th>
<th>位置</th>
<th>类型</th>
<th>状态</th>
<th>三维点位</th>
<th v-if="auth.isAdmin">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="d in devices" :key="d.id">
<td>{{ d.name }}</td>
<td>{{ d.channel }}</td>
<td>{{ d.location }}</td>
<td>{{ typeLabel[d.deviceType] }}</td>
<td>
<span class="st" :class="d.status">{{ statusLabel[d.status] }}</span>
<span v-if="!d.enabled" class="st off">停用</span>
</td>
<td class="mono">{{ fmtPoint(d.scenePoint) }}</td>
<td v-if="auth.isAdmin" class="ops">
<button class="btn ghost" type="button" @click="openPick(d)">三维打点</button>
<button
v-if="d.scenePoint"
class="btn ghost"
type="button"
@click="clearPoint(d)"
>
清点
</button>
<button class="btn ghost" type="button" @click="preview(d)">预览</button>
<button class="btn ghost" type="button" @click="toggleEnabled(d)">
{{ d.enabled ? '停用' : '启用' }}
</button>
<button class="btn ghost" type="button" @click="removeDevice(d)">删</button>
</td>
</tr>
<tr v-if="!devices.length">
<td :colspan="auth.isAdmin ? 7 : 6" class="empty">暂无摄像头</td>
</tr>
</tbody>
</table>
</div>
<p class="tip">
三维打点:打开 RTEngine 场景后点击地面拾取坐标并保存;大屏加载后自动显示摄像头标记,点击标记可播放视频。
</p>
</section>
<ScenePickDialog
:open="pickOpen"
:title="pickDevice ? `打点 · ${pickDevice.name}` : '三维打点'"
:initial="pickDevice?.scenePoint || null"
@close="pickOpen = false"
@save="onPickSave"
/>
<CameraPlayDialog
:open="playOpen"
:device-id="playDevice?.id || ''"
:title="playDevice?.name"
@close="playOpen = false"
/>
</div>
</template>
<style scoped>
.cameras-page { min-height: 0; }
.list-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.head-actions { display: flex; gap: 8px; }
.create-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
margin-bottom: 12px;
}
.create-row .field { min-width: 120px; }
.table-wrap { flex: 1; min-height: 0; overflow: auto; }
.ops { white-space: nowrap; }
.ops .btn { margin-right: 4px; }
.mono { font-family: var(--digital-font); font-size: 12px; }
.st {
display: inline-block;
padding: 1px 6px;
border-radius: 2px;
font-size: 11px;
margin-right: 4px;
}
.st.online { background: rgba(82, 196, 26, 0.15); color: #7dff9a; }
.st.offline { background: rgba(142, 163, 184, 0.15); color: #9bb0c4; }
.st.alarm { background: rgba(255, 77, 79, 0.15); color: #ffb0b0; }
.st.off { background: rgba(250, 173, 20, 0.12); color: #ffd666; }
.empty { text-align: center; color: var(--panel-muted); padding: 24px; }
.msg.ok { color: #7dcea0; }
.msg.err { color: #ffb0b0; }
.tip { margin-top: 10px; font-size: 12px; color: var(--panel-muted); }
</style>
...@@ -29,6 +29,7 @@ interface DevicePoint { ...@@ -29,6 +29,7 @@ interface DevicePoint {
gatewayId: string gatewayId: string
deviceId: string | null deviceId: string | null
name: string name: string
regType?: string
dataType: string dataType: string
modbusId: number modbusId: number
modbusRegister: string modbusRegister: string
...@@ -38,11 +39,34 @@ interface DevicePoint { ...@@ -38,11 +39,34 @@ interface DevicePoint {
scaleOverride: number | null scaleOverride: number | null
alarmLow: number | null alarmLow: number | null
alarmHigh: number | null alarmHigh: number | null
/** r=采集 w=写控 rw=可读写 */
rw?: 'r' | 'w' | 'rw'
enabled: boolean enabled: boolean
lastEng: number | null lastEng: number | null
lastTs: string | null lastTs: string | null
} }
function rwLabel(p: DevicePoint) {
const rw = p.rw || 'r'
if (rw === 'w') return '写控'
if (rw === 'rw') return '读写'
return '采集'
}
function isWritable(p: DevicePoint) {
const rw = p.rw || 'r'
return rw === 'w' || rw === 'rw'
}
/** Boolean / Bit / Coil / F5 → 开/关写入,不用填数字 */
function isBoolWrite(p: DevicePoint) {
const dt = (p.dataType || '').toLowerCase()
const rt = (p.regType || '').toUpperCase()
if (/bool|bit|coil/.test(dt)) return true
if (rt.includes('-F5') || /\b0X\b/.test(rt) || rt.includes('COIL')) return true
return false
}
type Tab = 'devices' | 'points' | 'gateways' type Tab = 'devices' | 'points' | 'gateways'
type Dialog = 'import' | 'createPoint' | 'createDevice' | 'editPoint' | 'viewDevice' | null type Dialog = 'import' | 'createPoint' | 'createDevice' | 'editPoint' | 'viewDevice' | null
...@@ -61,6 +85,9 @@ const pointsVersion = ref(1) ...@@ -61,6 +85,9 @@ 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 selectedDevice = ref<Device | null>(null)
const returnToDeviceView = ref(false) const returnToDeviceView = ref(false)
/** 写控点草稿值 pointId -> string */
const writeDrafts = reactive<Record<string, string>>({})
const writingId = ref<string | null>(null)
let viewRefreshTimer: number | undefined let viewRefreshTimer: number | undefined
const filterGatewayId = ref('') const filterGatewayId = ref('')
...@@ -450,6 +477,44 @@ async function syncGateway() { ...@@ -450,6 +477,44 @@ async function syncGateway() {
} }
} }
function draftValue(p: DevicePoint) {
if (writeDrafts[p.id] !== undefined) return writeDrafts[p.id]
if (p.lastEng !== null && p.lastEng !== undefined) return String(p.lastEng)
return ''
}
async function writePointValue(p: DevicePoint, forced?: number) {
if (!auth.isAdmin || !isWritable(p)) return
let value: number
if (forced !== undefined) {
value = forced
} else {
const raw = draftValue(p).trim()
value = Number(raw)
if (!Number.isFinite(value)) {
error.value = `请为「${p.name}」填写有效数值`
return
}
}
writingId.value = p.id
error.value = ''
try {
const r = await api<{ ok: boolean; message?: string }>(`/api/devices/points/${p.id}/write`, {
method: 'POST',
token: auth.token,
body: JSON.stringify({ value }),
})
const shown = isBoolWrite(p) ? (value ? '开' : '关') : String(value)
message.value = `已写入 ${p.name} = ${shown}${r.message ? `(${r.message})` : ''}`
writeDrafts[p.id] = String(value)
await refreshPointsOnly()
} catch (e) {
error.value = e instanceof Error ? e.message : '写入失败'
} finally {
writingId.value = null
}
}
function gatewayName(id: string) { function gatewayName(id: string) {
return gateways.value.find((g) => g.id === id)?.name || id return gateways.value.find((g) => g.id === id)?.name || id
} }
...@@ -587,6 +652,7 @@ onUnmounted(stopViewRefresh) ...@@ -587,6 +652,7 @@ onUnmounted(stopViewRefresh)
<tr> <tr>
<th>名称</th> <th>名称</th>
<th>地址</th> <th>地址</th>
<th>读写</th>
<th>类型</th> <th>类型</th>
<th>设备</th> <th>设备</th>
<th>ItemID</th> <th>ItemID</th>
...@@ -594,6 +660,7 @@ onUnmounted(stopViewRefresh) ...@@ -594,6 +660,7 @@ onUnmounted(stopViewRefresh)
<th>告警下限</th> <th>告警下限</th>
<th>告警上限</th> <th>告警上限</th>
<th>实时值</th> <th>实时值</th>
<th v-if="auth.isAdmin">写入</th>
<th v-if="auth.isAdmin">操作</th> <th v-if="auth.isAdmin">操作</th>
</tr> </tr>
</thead> </thead>
...@@ -601,20 +668,61 @@ onUnmounted(stopViewRefresh) ...@@ -601,20 +668,61 @@ onUnmounted(stopViewRefresh)
<tr v-for="p in filteredPoints" :key="p.id"> <tr v-for="p in filteredPoints" :key="p.id">
<td>{{ p.name }}</td> <td>{{ p.name }}</td>
<td>{{ p.modbusAddress }}</td> <td>{{ p.modbusAddress }}</td>
<td>{{ rwLabel(p) }}</td>
<td>{{ p.dataType }}</td> <td>{{ p.dataType }}</td>
<td>{{ deviceName(p.deviceId) }}</td> <td>{{ deviceName(p.deviceId) }}</td>
<td class="mono">{{ p.itemId }}</td> <td class="mono">{{ p.itemId }}</td>
<td>{{ p.scaleOverride ?? p.scale }}</td> <td>{{ p.scaleOverride ?? p.scale }}</td>
<td>{{ p.alarmLow ?? '—' }}</td> <td>{{ p.alarmLow ?? '—' }}</td>
<td>{{ p.alarmHigh ?? '—' }}</td> <td>{{ p.alarmHigh ?? '—' }}</td>
<td>{{ p.lastEng ?? '—' }}</td> <td>{{ p.rw === 'w' ? '—' : (p.lastEng ?? '—') }}</td>
<td v-if="auth.isAdmin">
<div v-if="isWritable(p) && isBoolWrite(p)" class="write-cell">
<button
class="btn bool-on"
type="button"
:disabled="writingId === p.id"
@click="writePointValue(p, 1)"
>
开
</button>
<button
class="btn ghost bool-off"
type="button"
:disabled="writingId === p.id"
@click="writePointValue(p, 0)"
>
关
</button>
</div>
<div v-else-if="isWritable(p)" class="write-cell">
<input
class="write-input"
type="number"
step="any"
:value="draftValue(p)"
:disabled="writingId === p.id"
@input="writeDrafts[p.id] = ($event.target as HTMLInputElement).value"
@keydown.enter.prevent="writePointValue(p)"
/>
<button
class="btn"
type="button"
:disabled="writingId === p.id"
@click="writePointValue(p)"
>
{{ writingId === p.id ? '…' : '写' }}
</button>
</div>
<span v-else class="muted">—</span>
</td>
<td v-if="auth.isAdmin" class="ops"> <td v-if="auth.isAdmin" class="ops">
<button class="btn ghost" type="button" @click="openEditPoint(p)">编辑</button> <button class="btn ghost" type="button" @click="openEditPoint(p)">编辑</button>
<button class="btn ghost" type="button" @click="removePoint(p)">删</button> <button class="btn ghost" type="button" @click="removePoint(p)">删</button>
</td> </td>
</tr> </tr>
<tr v-if="!filteredPoints.length"> <tr v-if="!filteredPoints.length">
<td :colspan="auth.isAdmin ? 10 : 9" class="empty"> <td :colspan="auth.isAdmin ? 12 : 10" class="empty">
暂无点位,点击右上角「导入 CSV」或「新增点位」 暂无点位,点击右上角「导入 CSV」或「新增点位」
</td> </td>
</tr> </tr>
...@@ -648,7 +756,7 @@ onUnmounted(stopViewRefresh) ...@@ -648,7 +756,7 @@ onUnmounted(stopViewRefresh)
<!-- 设备详情 / 点位数值 --> <!-- 设备详情 / 点位数值 -->
<div v-if="dialog === 'viewDevice' && selectedDevice" class="modal-mask" @click.self="closeDialog"> <div v-if="dialog === 'viewDevice' && selectedDevice" class="modal-mask" @click.self="closeDialog">
<div class="modal panel modal-lg"> <div class="modal panel modal-xl">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title"> <div class="panel-title">
<div class="vbar" /> <div class="vbar" />
...@@ -674,19 +782,23 @@ onUnmounted(stopViewRefresh) ...@@ -674,19 +782,23 @@ onUnmounted(stopViewRefresh)
</span> </span>
</div> </div>
<p v-if="error" class="msg err">{{ error }}</p>
<p v-if="message" class="msg ok">{{ message }}</p>
<div class="detail-table-wrap"> <div class="detail-table-wrap">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>点位名称</th> <th>点位名称</th>
<th>地址</th> <th>地址</th>
<th>读写</th>
<th>类型</th> <th>类型</th>
<th>工程值</th> <th>工程值</th>
<th>告警下限</th> <th>告警下限</th>
<th>告警上限</th> <th>告警上限</th>
<th>倍率</th> <th>倍率</th>
<th>更新时间</th> <th>更新时间</th>
<th>ItemID</th> <th v-if="auth.isAdmin">写入</th>
<th v-if="auth.isAdmin">操作</th> <th v-if="auth.isAdmin">操作</th>
</tr> </tr>
</thead> </thead>
...@@ -694,15 +806,55 @@ onUnmounted(stopViewRefresh) ...@@ -694,15 +806,55 @@ onUnmounted(stopViewRefresh)
<tr v-for="p in selectedDevicePoints" :key="p.id"> <tr v-for="p in selectedDevicePoints" :key="p.id">
<td>{{ p.name }}</td> <td>{{ p.name }}</td>
<td>{{ p.modbusAddress }}</td> <td>{{ p.modbusAddress }}</td>
<td>{{ rwLabel(p) }}</td>
<td>{{ p.dataType }}</td> <td>{{ p.dataType }}</td>
<td class="eng" :class="{ muted: p.lastEng === null }"> <td class="eng" :class="{ muted: p.lastEng === null }">
{{ formatEng(p.lastEng) }} {{ p.rw === 'w' ? '—' : formatEng(p.lastEng) }}
</td> </td>
<td>{{ p.alarmLow ?? '—' }}</td> <td>{{ p.alarmLow ?? '—' }}</td>
<td>{{ p.alarmHigh ?? '—' }}</td> <td>{{ p.alarmHigh ?? '—' }}</td>
<td>{{ p.scaleOverride ?? p.scale }}</td> <td>{{ p.scaleOverride ?? p.scale }}</td>
<td class="mono ts">{{ formatTs(p.lastTs) }}</td> <td class="mono ts">{{ formatTs(p.lastTs) }}</td>
<td class="mono">{{ p.itemId }}</td> <td v-if="auth.isAdmin">
<div v-if="isWritable(p) && isBoolWrite(p)" class="write-cell">
<button
class="btn bool-on"
type="button"
:disabled="writingId === p.id"
@click.stop="writePointValue(p, 1)"
>
{{ writingId === p.id ? '…' : '开' }}
</button>
<button
class="btn ghost bool-off"
type="button"
:disabled="writingId === p.id"
@click.stop="writePointValue(p, 0)"
>
关
</button>
</div>
<div v-else-if="isWritable(p)" class="write-cell">
<input
class="write-input"
type="number"
step="any"
:value="draftValue(p)"
:disabled="writingId === p.id"
@input="writeDrafts[p.id] = ($event.target as HTMLInputElement).value"
@keydown.enter.prevent="writePointValue(p)"
/>
<button
class="btn"
type="button"
:disabled="writingId === p.id"
@click.stop="writePointValue(p)"
>
{{ writingId === p.id ? '写入中…' : '写入' }}
</button>
</div>
<span v-else class="muted">—</span>
</td>
<td v-if="auth.isAdmin" class="ops"> <td v-if="auth.isAdmin" class="ops">
<button <button
class="btn ghost" class="btn ghost"
...@@ -714,12 +866,14 @@ onUnmounted(stopViewRefresh) ...@@ -714,12 +866,14 @@ onUnmounted(stopViewRefresh)
</td> </td>
</tr> </tr>
<tr v-if="!selectedDevicePoints.length"> <tr v-if="!selectedDevicePoints.length">
<td :colspan="auth.isAdmin ? 10 : 9" class="empty">该设备暂无绑定点位</td> <td :colspan="auth.isAdmin ? 11 : 9" class="empty">该设备暂无绑定点位</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<p class="hint">对话框打开期间每 3 秒自动刷新点位数值;无采集数据时工程值显示为 —。</p> <p class="hint">
对话框打开期间每 3 秒自动刷新采集点数值;写控点可在「写入」列下发(管理员)。
</p>
</div> </div>
</div> </div>
...@@ -1031,7 +1185,7 @@ onUnmounted(stopViewRefresh) ...@@ -1031,7 +1185,7 @@ onUnmounted(stopViewRefresh)
color: var(--panel-title); color: var(--panel-title);
} }
.detail-table-wrap { .detail-table-wrap {
max-height: min(56vh, 520px); max-height: min(68vh, 720px);
overflow: auto; overflow: auto;
} }
.eng { .eng {
...@@ -1062,7 +1216,11 @@ onUnmounted(stopViewRefresh) ...@@ -1062,7 +1216,11 @@ onUnmounted(stopViewRefresh)
max-height: 90vh; max-height: 90vh;
overflow: auto; overflow: auto;
} }
.modal-lg { width: min(720px, 94vw); } .modal-lg { width: min(860px, 96vw); }
.modal-xl {
width: min(1280px, 98vw);
max-height: 92vh;
}
.modal-actions { .modal-actions {
margin-top: 14px; margin-top: 14px;
display: flex; display: flex;
...@@ -1073,4 +1231,35 @@ onUnmounted(stopViewRefresh) ...@@ -1073,4 +1231,35 @@ onUnmounted(stopViewRefresh)
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 10px; gap: 10px;
} }
.write-cell {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 140px;
white-space: nowrap;
}
.ops {
white-space: nowrap;
}
.ops .btn {
margin-right: 4px;
}
.write-input {
width: 88px;
padding: 4px 6px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.25);
color: inherit;
}
.bool-on {
min-width: 44px;
padding: 4px 10px;
}
.bool-off {
min-width: 44px;
padding: 4px 10px;
}
.muted { color: var(--panel-muted); }
.msg.ok { color: #7dcea0; }
</style> </style>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import SceneViewport from '@/components/scene/SceneViewport.vue'
import AppIcon from '@/components/icons/AppIcon.vue' import AppIcon from '@/components/icons/AppIcon.vue'
import PanelBox from '@/components/screen/PanelBox.vue'
import AlarmScroll from '@/components/panels/AlarmScroll.vue' import AlarmScroll from '@/components/panels/AlarmScroll.vue'
import ColdScroll from '@/components/panels/ColdScroll.vue' import ColdScroll from '@/components/panels/ColdScroll.vue'
import VavScroll from '@/components/panels/VavScroll.vue' import VavScroll from '@/components/panels/VavScroll.vue'
...@@ -15,18 +15,16 @@ const vavRunning = computed(() => snap.value.vav.filter((v) => v.running).length ...@@ -15,18 +15,16 @@ const vavRunning = computed(() => snap.value.vav.filter((v) => v.running).length
const coldWarnCount = computed(() => snap.value.fridges.filter((f) => f.temp > -19).length) const coldWarnCount = computed(() => snap.value.fridges.filter((f) => f.temp > -19).length)
const LAB_VIDEO = '/cams/lab-seq.mp4'
const cameras = [ const cameras = [
{ name: '走廊', src: '/cams/cam-corridor.png' }, { name: '走廊', src: LAB_VIDEO },
{ name: 'PCR区', src: '/cams/cam-pcr.png' }, { name: 'PCR区', src: LAB_VIDEO },
{ name: '样本库', src: '/cams/cam-sample.png' }, { name: '样本库', src: LAB_VIDEO },
{ name: '机房', src: '/cams/cam-room.png' }, { name: '机房', src: LAB_VIDEO },
{ name: '门禁口', src: '/cams/cam-gate.png' },
{ name: '冷链库', src: '/cams/cam-cold.png' },
] ]
const alarms = computed(() => { const alarms = computed(() => {
const items: { level: 'red' | 'amber' | 'calm'; title: string; sub: string; state: string }[] = [] const items: { level: 'red' | 'amber' | 'calm'; title: string; sub: string; state: string }[] = []
// 后端已下发 pointAlarms(含空数组)时以点位阈值为准
if (Array.isArray(snap.value.pointAlarms)) { if (Array.isArray(snap.value.pointAlarms)) {
for (const a of snap.value.pointAlarms) { for (const a of snap.value.pointAlarms) {
items.push({ items.push({
...@@ -38,15 +36,24 @@ const alarms = computed(() => { ...@@ -38,15 +36,24 @@ const alarms = computed(() => {
} }
return items return items
} }
// 旧后端无 pointAlarms 字段时回退 Mock
for (const r of snap.value.rooms) { for (const r of snap.value.rooms) {
for (const a of r.alarms) { for (const a of r.alarms) {
items.push({ level: 'red', title: `${r.name}${a}`, sub: `压差 ${r.pressure.toFixed(1)} Pa`, state: '未消除' }) items.push({
level: 'red',
title: `${r.name}${a}`,
sub: `压差 ${r.pressure.toFixed(1)} Pa`,
state: '未消除',
})
} }
} }
for (const f of snap.value.fridges) { for (const f of snap.value.fridges) {
if (f.temp > -19) { if (f.temp > -19) {
items.push({ level: 'amber', title: `${f.name}温度超限 ${f.temp.toFixed(1)} ℃`, sub: '冷链监测', state: '处理中' }) items.push({
level: 'amber',
title: `${f.name}温度超限 ${f.temp.toFixed(1)} ℃`,
sub: '冷链监测',
state: '处理中',
})
} }
} }
for (const tip of snap.value.hvac.alarms) { for (const tip of snap.value.hvac.alarms) {
...@@ -57,17 +64,12 @@ const alarms = computed(() => { ...@@ -57,17 +64,12 @@ const alarms = computed(() => {
</script> </script>
<template> <template>
<div class="overview-wrap"> <div class="overview-float">
<div class="overview"> <div class="side left">
<div class="col"> <PanelBox title="环境参数监测">
<section class="panel"> <template #extra>
<div class="panel-head"> <span class="badge">实时</span>
<div class="panel-title"> </template>
<AppIcon name="panel-env" />
<div class="vbar" /><h3>环境参数监测</h3>
</div>
<div class="badge">实时</div>
</div>
<div class="metric-grid"> <div class="metric-grid">
<div class="metric"> <div class="metric">
<AppIcon name="metric-pressure" /> <AppIcon name="metric-pressure" />
...@@ -98,89 +100,94 @@ const alarms = computed(() => { ...@@ -98,89 +100,94 @@ const alarms = computed(() => {
</div> </div>
</div> </div>
</div> </div>
</section> </PanelBox>
<section class="panel vav-panel"> <PanelBox title="通风柜 VAV 监控">
<div class="panel-head"> <template #extra>
<div class="panel-title"> <span class="badge">{{ vavRunning }}/{{ snap.vav.length }} 运行</span>
<AppIcon name="panel-vav" /> </template>
<div class="vbar" /><h3>通风柜 VAV 监控</h3>
</div>
<div class="badge">{{ vavRunning }}/{{ snap.vav.length }} 运行</div>
</div>
<VavScroll :items="snap.vav" /> <VavScroll :items="snap.vav" />
</section> </PanelBox>
<section class="panel cold-panel"> <PanelBox title="冷链监测">
<div class="panel-head"> <template #extra>
<div class="panel-title"> <span class="badge" :class="{ amber: coldWarnCount > 0 }">
<AppIcon name="panel-cold" />
<div class="vbar" /><h3>冷链监测</h3>
</div>
<div class="badge" :class="{ amber: coldWarnCount > 0 }">
{{ coldWarnCount > 0 ? `${coldWarnCount}台超限` : '全部正常' }} {{ coldWarnCount > 0 ? `${coldWarnCount}台超限` : '全部正常' }}
</div> </span>
</div> </template>
<ColdScroll :items="snap.fridges" /> <ColdScroll :items="snap.fridges" />
</section> </PanelBox>
</div> </div>
<SceneViewport /> <div class="center-pass" aria-hidden="true" />
<div class="col"> <div class="side right">
<section class="panel hvac-panel"> <PanelBox title="空调及排风机组">
<div class="panel-head"> <template #extra>
<div class="panel-title"> <span class="badge">{{ snap.hvac.systemRunning ? '运行中' : '停机' }}</span>
<AppIcon name="panel-hvac" /> </template>
<div class="vbar" /><h3>空调及排风机组</h3>
</div>
<div class="badge">{{ snap.hvac.systemRunning ? '运行中' : '停机' }}</div>
</div>
<div class="hvac-table" role="table"> <div class="hvac-table" role="table">
<div class="hvac-thead" role="row"> <div class="hvac-thead" role="row">
<span role="columnheader">参数</span> <span role="columnheader">参数</span>
<span role="columnheader">数值</span> <span role="columnheader">数值</span>
</div> </div>
<div class="hvac-tbody" role="rowgroup"> <div class="hvac-tbody" role="rowgroup">
<div class="hvac-row" role="row"><span>送风温度</span><b>{{ snap.hvac.supplyTemp.toFixed(1) }} ℃</b></div> <div class="hvac-row" role="row">
<div class="hvac-row" role="row"><span>送风湿度</span><b>{{ snap.hvac.supplyHumidity }} %</b></div> <span>送风温度</span><b>{{ snap.hvac.supplyTemp.toFixed(1) }} ℃</b>
<div class="hvac-row" role="row"><span>送风压力</span><b>{{ snap.hvac.supplyPressure }} Pa</b></div>
<div class="hvac-row" role="row"><span>风机频率</span><b>{{ snap.hvac.supplyFanHz.toFixed(1) }} Hz</b></div>
<div class="hvac-row" role="row"><span>排风压力</span><b>{{ snap.hvac.exhaustPressure }} Pa</b></div>
<div class="hvac-row" role="row"><span>新风温度</span><b>{{ snap.hvac.freshAirTemp.toFixed(1) }} ℃</b></div>
<div class="hvac-row" role="row"><span>滤网状态</span><b class="green">正常 · 剩余 {{ snap.hvac.filterRemainPct }}%</b></div>
<div class="hvac-row" role="row"><span>启停控制</span><b class="cyan">{{ snap.hvac.mode === 'auto' ? '自动模式' : '手动模式' }} · 已授权</b></div>
</div> </div>
<div class="hvac-row" role="row">
<span>送风湿度</span><b>{{ snap.hvac.supplyHumidity }} %</b>
</div> </div>
</section> <div class="hvac-row" role="row">
<span>送风压力</span><b>{{ snap.hvac.supplyPressure }} Pa</b>
<section class="panel alarm-panel">
<div class="panel-head">
<div class="panel-title">
<AppIcon name="panel-alarm" />
<div class="vbar" /><h3>报警响应</h3>
</div> </div>
<div class="hvac-row" role="row">
<span>风机频率</span><b>{{ snap.hvac.supplyFanHz.toFixed(1) }} Hz</b>
</div> </div>
<AlarmScroll :items="alarms" /> <div class="hvac-row" role="row">
</section> <span>排风压力</span><b>{{ snap.hvac.exhaustPressure }} Pa</b>
</div>
<section class="panel cam-panel"> <div class="hvac-row" role="row">
<div class="panel-head"> <span>新风温度</span><b>{{ snap.hvac.freshAirTemp.toFixed(1) }} ℃</b>
<div class="panel-title">
<AppIcon name="panel-video" />
<div class="vbar" /><h3>视频监控</h3>
</div> </div>
<div class="badge amber">海康 · 占位</div> <div class="hvac-row" role="row">
<span>滤网状态</span>
<b class="green">正常 · 剩余 {{ snap.hvac.filterRemainPct }}%</b>
</div> </div>
<div class="hvac-row" role="row">
<span>启停控制</span>
<b class="cyan">
{{ snap.hvac.mode === 'auto' ? '自动模式' : '手动模式' }} · 已授权
</b>
</div>
</div>
</div>
</PanelBox>
<PanelBox title="报警响应">
<AlarmScroll :items="alarms" />
</PanelBox>
<PanelBox title="视频监控">
<template #extra>
<span class="badge amber">海康 · 占位</span>
</template>
<div class="cam-grid"> <div class="cam-grid">
<div v-for="c in cameras" :key="c.name" class="cam"> <div v-for="c in cameras" :key="c.name" class="cam">
<img :src="c.src" :alt="c.name" /> <video
class="cam-video"
:src="c.src"
muted
loop
autoplay
playsinline
:aria-label="c.name"
/>
<AppIcon name="cam-badge" class-name="cam-corner" /> <AppIcon name="cam-badge" class-name="cam-corner" />
<div class="cam-name">{{ c.name }}</div> <div class="cam-name">{{ c.name }}</div>
</div> </div>
</div> </div>
</section> </PanelBox>
</div>
</div> </div>
<div class="kpi-bar"> <div class="kpi-bar">
...@@ -192,7 +199,10 @@ const alarms = computed(() => { ...@@ -192,7 +199,10 @@ const alarms = computed(() => {
</div> </div>
<div class="k-center"> <div class="k-center">
<div class="digital">{{ snap.metering.powerKwh.toFixed(1) }}</div> <div class="digital">{{ snap.metering.powerKwh.toFixed(1) }}</div>
<div class="k-trend">较昨日 {{ snap.metering.powerTrendPct > 0 ? '↑' : '↓' }} {{ Math.abs(snap.metering.powerTrendPct) }}%</div> <div class="k-trend">
较昨日 {{ snap.metering.powerTrendPct > 0 ? '↑' : '↓' }}
{{ Math.abs(snap.metering.powerTrendPct) }}%
</div>
</div> </div>
</div> </div>
<div class="kpi kpi--water"> <div class="kpi kpi--water">
...@@ -203,7 +213,10 @@ const alarms = computed(() => { ...@@ -203,7 +213,10 @@ const alarms = computed(() => {
</div> </div>
<div class="k-center"> <div class="k-center">
<div class="digital">{{ snap.metering.waterM3.toFixed(1) }}</div> <div class="digital">{{ snap.metering.waterM3.toFixed(1) }}</div>
<div class="k-trend">较昨日 {{ snap.metering.waterTrendPct > 0 ? '↑' : '↓' }} {{ Math.abs(snap.metering.waterTrendPct) }}%</div> <div class="k-trend">
较昨日 {{ snap.metering.waterTrendPct > 0 ? '↑' : '↓' }}
{{ Math.abs(snap.metering.waterTrendPct) }}%
</div>
</div> </div>
</div> </div>
<div class="kpi kpi--cool"> <div class="kpi kpi--cool">
...@@ -224,8 +237,15 @@ const alarms = computed(() => { ...@@ -224,8 +237,15 @@ const alarms = computed(() => {
<div class="k-label">设备在线</div> <div class="k-label">设备在线</div>
</div> </div>
<div class="k-center"> <div class="k-center">
<div class="digital">{{ snap.online.devicesOnline }}/{{ snap.online.devicesTotal }}</div> <div class="digital">
<div class="k-trend">在线率 {{ ((snap.online.devicesOnline / Math.max(1, snap.online.devicesTotal)) * 100).toFixed(1) }}%</div> {{ snap.online.devicesOnline }}/{{ snap.online.devicesTotal }}
</div>
<div class="k-trend">
在线率
{{
((snap.online.devicesOnline / Math.max(1, snap.online.devicesTotal)) * 100).toFixed(1)
}}%
</div>
</div> </div>
</div> </div>
<div class="kpi kpi--access"> <div class="kpi kpi--access">
...@@ -244,35 +264,198 @@ const alarms = computed(() => { ...@@ -244,35 +264,198 @@ const alarms = computed(() => {
</template> </template>
<style scoped> <style scoped>
.overview-wrap { .overview-float {
flex: 1; min-height: 0; width: 100%; position: relative;
display: flex; flex-direction: column; gap: 14px; width: 100%;
overflow: hidden; height: 100%;
min-height: 0;
display: grid;
grid-template-columns: 460px 1fr 460px;
grid-template-rows: 1fr 96px;
gap: 10px 12px;
pointer-events: none;
} }
.overview { .side {
flex: 1; min-height: 0; display: flex; gap: 14px; width: 100%; min-width: 0;
overflow: hidden; min-height: 0;
display: grid;
gap: 10px;
pointer-events: auto;
}
.left {
grid-template-rows: minmax(140px, 0.55fr) minmax(0, 1fr) minmax(0, 1fr);
}
.right {
/* 空调表自适应,告警约 3 条,剩余给视频(避免 1fr 被挤没) */
grid-template-rows: auto minmax(210px, 0.7fr) minmax(240px, 1.2fr);
}
.center-pass {
pointer-events: none;
} }
.kpi-bar { .kpi-bar {
height: 88px; flex: 0 0 88px; grid-column: 1 / -1;
display: flex; gap: 14px; padding: 0; height: 96px;
display: flex;
gap: 12px;
pointer-events: auto;
}
.badge {
padding: 1px 7px;
border-radius: 2px;
font-size: 10px;
letter-spacing: 0.06em;
background: rgba(82, 196, 26, 0.14);
color: #7dff9a;
border: 1px solid rgba(82, 196, 26, 0.28);
}
.badge.amber {
background: rgba(250, 173, 20, 0.14);
color: #ffd666;
border-color: rgba(250, 173, 20, 0.35);
}
.metric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.metric {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
min-height: 58px;
border-radius: 4px;
background: rgba(6, 28, 56, 0.45);
border: 1px solid rgba(0, 247, 255, 0.14);
}
.m-txt {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.m-label {
font-size: 11px;
color: var(--panel-muted);
}
.m-value {
font-size: 20px;
font-weight: 600;
font-family: var(--font-num);
color: #fff;
line-height: 1;
}
.m-value.green {
color: var(--green);
}
.hvac-table {
border: 1px solid rgba(0, 247, 255, 0.22);
border-radius: 4px;
overflow: hidden;
background: rgba(6, 18, 36, 0.35);
}
.hvac-thead,
.hvac-row {
display: grid;
grid-template-columns: 1fr 1.35fr;
align-items: center;
gap: 8px;
min-height: 26px;
padding: 0 12px;
font-size: 12px;
}
.hvac-thead {
background: rgba(0, 247, 255, 0.1);
border-bottom: 1px solid rgba(0, 247, 255, 0.28);
color: var(--accent);
font-weight: 600;
}
.hvac-thead span:last-child {
text-align: right;
}
.hvac-row {
color: var(--panel-muted);
border-bottom: 1px solid rgba(0, 247, 255, 0.12);
background: rgba(14, 36, 68, 0.22);
}
.hvac-row:nth-child(even) {
background: rgba(22, 56, 104, 0.28);
}
.hvac-row:last-child {
border-bottom: none;
}
.hvac-row b {
color: #fff;
font-weight: 500;
font-family: var(--font-num);
text-align: right;
justify-self: end;
}
.hvac-row b.green {
color: var(--green);
}
.hvac-row b.cyan {
color: var(--accent);
}
.cam-grid {
flex: 1;
min-height: 160px;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 6px;
}
.cam {
position: relative; position: relative;
z-index: 2; min-height: 0;
height: 100%;
border-radius: 4px;
overflow: hidden;
border: 1px solid rgba(0, 247, 255, 0.22);
background: #04101f;
}
.cam-video,
.cam img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
background: #000;
}
.cam :deep(.cam-corner) {
position: absolute;
left: 6px;
top: 6px;
width: 22px;
height: 22px;
}
.cam-name {
position: absolute;
right: 6px;
top: 6px;
font-size: 10px;
background: rgba(4, 16, 31, 0.6);
padding: 1px 6px;
border-radius: 3px;
} }
.kpi { .kpi {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
flex: 1; flex: 1;
padding: 0 14px; padding: 0 14px;
border-radius: 12px; border-radius: 4px;
border: 1px solid transparent; border: 1px solid transparent;
background: linear-gradient(145deg, rgba(18, 40, 72, 0.72), rgba(8, 18, 36, 0.65)); background: linear-gradient(145deg, rgba(0, 28, 56, 0.72), rgba(0, 12, 28, 0.62));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
backdrop-filter: blur(8px);
} }
.kpi .k-glow { .kpi .k-glow {
position: absolute; position: absolute;
left: -16px; top: -28px; left: -16px;
width: 100px; height: 100px; top: -28px;
width: 100px;
height: 100px;
border-radius: 50%; border-radius: 50%;
pointer-events: none; pointer-events: none;
opacity: 0.55; opacity: 0.55;
...@@ -289,7 +472,7 @@ const alarms = computed(() => { ...@@ -289,7 +472,7 @@ const alarms = computed(() => {
gap: 8px; gap: 8px;
} }
.kpi .k-label { .kpi .k-label {
font-size: 15px; font-size: 14px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.3px; letter-spacing: 0.3px;
white-space: nowrap; white-space: nowrap;
...@@ -308,7 +491,7 @@ const alarms = computed(() => { ...@@ -308,7 +491,7 @@ const alarms = computed(() => {
} }
.kpi .digital { .kpi .digital {
padding: 2px 10px; padding: 2px 10px;
border-radius: 6px; border-radius: 4px;
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
font-family: var(--font-num); font-family: var(--font-num);
...@@ -320,180 +503,103 @@ const alarms = computed(() => { ...@@ -320,180 +503,103 @@ const alarms = computed(() => {
font-size: 11px; font-size: 11px;
white-space: nowrap; white-space: nowrap;
} }
.kpi :deep(.kpi-icon), .kpi :deep(.kpi-icon) {
.kpi :deep(.k-icon) { width: 34px;
width: 34px; height: 34px; height: 34px;
flex: 0 0 auto; flex: 0 0 auto;
} }
/* 用电 · 琥珀金 */
.kpi--power { .kpi--power {
border-color: rgba(255, 180, 64, 0.42); border-color: rgba(255, 180, 64, 0.42);
background: linear-gradient(145deg, rgba(72, 48, 12, 0.55), rgba(18, 28, 40, 0.7));
} }
.kpi--power .k-glow { background: radial-gradient(circle, rgba(255, 180, 64, 0.35), transparent 68%); } .kpi--power .k-glow {
.kpi--power .k-label { color: #f0c878; } background: radial-gradient(circle, rgba(255, 180, 64, 0.35), transparent 68%);
}
.kpi--power .k-label {
color: #f0c878;
}
.kpi--power .digital { .kpi--power .digital {
color: #ffe08a; color: #ffe08a;
background: rgba(40, 28, 8, 0.75); background: rgba(40, 28, 8, 0.75);
border: 1px solid rgba(255, 180, 64, 0.5); border: 1px solid rgba(255, 180, 64, 0.5);
box-shadow: 0 0 16px rgba(255, 180, 64, 0.12);
} }
.kpi--power .k-trend { color: #ffcf5c; } .kpi--power .k-trend {
color: #ffcf5c;
/* 用水 · 水色蓝 */ }
.kpi--water { .kpi--water {
border-color: rgba(56, 189, 248, 0.42); border-color: rgba(56, 189, 248, 0.42);
background: linear-gradient(145deg, rgba(12, 48, 78, 0.6), rgba(10, 22, 40, 0.7));
} }
.kpi--water .k-glow { background: radial-gradient(circle, rgba(56, 189, 248, 0.32), transparent 68%); } .kpi--water .k-glow {
.kpi--water .k-label { color: #7dd3fc; } background: radial-gradient(circle, rgba(56, 189, 248, 0.32), transparent 68%);
}
.kpi--water .k-label {
color: #7dd3fc;
}
.kpi--water .digital { .kpi--water .digital {
color: #7dd3fc; color: #7dd3fc;
background: rgba(6, 28, 48, 0.8); background: rgba(6, 28, 48, 0.8);
border: 1px solid rgba(56, 189, 248, 0.5); border: 1px solid rgba(56, 189, 248, 0.5);
box-shadow: 0 0 16px rgba(56, 189, 248, 0.12);
} }
.kpi--water .k-trend { color: #38bdf8; } .kpi--water .k-trend {
color: #38bdf8;
/* 冷量 · 冰蓝 */ }
.kpi--cool { .kpi--cool {
border-color: rgba(103, 232, 249, 0.4); border-color: rgba(103, 232, 249, 0.4);
background: linear-gradient(145deg, rgba(10, 56, 72, 0.58), rgba(10, 22, 40, 0.7));
} }
.kpi--cool .k-glow { background: radial-gradient(circle, rgba(103, 232, 249, 0.3), transparent 68%); } .kpi--cool .k-glow {
.kpi--cool .k-label { color: #a5f3fc; } background: radial-gradient(circle, rgba(103, 232, 249, 0.3), transparent 68%);
}
.kpi--cool .k-label {
color: #a5f3fc;
}
.kpi--cool .digital { .kpi--cool .digital {
color: #a5f3fc; color: #a5f3fc;
background: rgba(6, 36, 48, 0.8); background: rgba(6, 36, 48, 0.8);
border: 1px solid rgba(103, 232, 249, 0.48); border: 1px solid rgba(103, 232, 249, 0.48);
box-shadow: 0 0 16px rgba(103, 232, 249, 0.12);
} }
.kpi--cool .k-trend { color: #67e8f9; } .kpi--cool .k-trend {
color: #67e8f9;
/* 设备 · 翠绿 */ }
.kpi--device { .kpi--device {
border-color: rgba(52, 211, 153, 0.42); border-color: rgba(52, 211, 153, 0.42);
background: linear-gradient(145deg, rgba(10, 56, 40, 0.55), rgba(10, 22, 36, 0.7));
} }
.kpi--device .k-glow { background: radial-gradient(circle, rgba(52, 211, 153, 0.3), transparent 68%); } .kpi--device .k-glow {
.kpi--device .k-label { color: #6ee7b7; } background: radial-gradient(circle, rgba(52, 211, 153, 0.3), transparent 68%);
}
.kpi--device .k-label {
color: #6ee7b7;
}
.kpi--device .digital { .kpi--device .digital {
color: #6ee7b7; color: #6ee7b7;
background: rgba(6, 36, 28, 0.8); background: rgba(6, 36, 28, 0.8);
border: 1px solid rgba(52, 211, 153, 0.48); border: 1px solid rgba(52, 211, 153, 0.48);
box-shadow: 0 0 16px rgba(52, 211, 153, 0.12);
} }
.kpi--device .k-trend { color: #34d399; } .kpi--device .k-trend {
color: #34d399;
/* 门禁 · 靛蓝紫 */ }
.kpi--access { .kpi--access {
border-color: rgba(167, 139, 250, 0.42); border-color: rgba(167, 139, 250, 0.42);
background: linear-gradient(145deg, rgba(48, 28, 78, 0.55), rgba(12, 18, 40, 0.7));
} }
.kpi--access .k-glow { background: radial-gradient(circle, rgba(167, 139, 250, 0.32), transparent 68%); } .kpi--access .k-glow {
.kpi--access .k-label { color: #c4b5fd; } background: radial-gradient(circle, rgba(167, 139, 250, 0.32), transparent 68%);
}
.kpi--access .k-label {
color: #c4b5fd;
}
.kpi--access .digital { .kpi--access .digital {
color: #ddd6fe; color: #ddd6fe;
background: rgba(28, 16, 48, 0.8); background: rgba(28, 16, 48, 0.8);
border: 1px solid rgba(167, 139, 250, 0.5); border: 1px solid rgba(167, 139, 250, 0.5);
box-shadow: 0 0 16px rgba(167, 139, 250, 0.12);
} }
.kpi--access .k-trend { color: #a78bfa; } .kpi--access .k-trend {
.col { color: #a78bfa;
width: 330px; flex: 0 0 330px;
display: flex; flex-direction: column; gap: 10px; min-height: 0;
overflow: hidden;
} }
.vav-panel, .cold-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.vav-panel :deep(.vav-viewport), /* 子滚动区撑满 PanelBox body */
.cold-panel :deep(.cold-viewport) { .side :deep(.vav-viewport),
flex: 1; .side :deep(.cold-viewport),
min-height: 0; .side :deep(.alarm-viewport) {
height: auto;
overflow: hidden;
}
.hvac-panel { flex: 0 0 auto; }
.alarm-panel { flex: 0 0 auto; }
.cam-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.metric {
display: flex; align-items: center; gap: 8px;
padding: 10px; min-height: 64px; border-radius: 8px;
background: var(--card-bg); border: 1px solid var(--card-bd);
}
.m-txt { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.m-label { font-size: 11px; color: var(--label); }
.m-value { font-size: 20px; font-weight: 600; font-family: var(--font-num); color: #fff; line-height: 1; }
.m-value.green { color: var(--green); }
.hvac-table {
border: 1px solid rgba(62, 200, 255, 0.28);
border-radius: 8px;
overflow: hidden;
background: rgba(6, 18, 36, 0.35);
}
.hvac-thead,
.hvac-row {
display: grid;
grid-template-columns: 1fr 1.35fr;
align-items: center;
gap: 8px;
min-height: 26px;
padding: 0 12px;
font-size: 12px;
}
.hvac-thead {
background: rgba(62, 200, 255, 0.12);
border-bottom: 1px solid rgba(62, 200, 255, 0.32);
color: var(--cyan-lt);
font-weight: 600;
letter-spacing: 0.4px;
}
.hvac-thead span:last-child { text-align: right; }
.hvac-row {
color: var(--label);
border-bottom: 1px solid rgba(62, 200, 255, 0.16);
background: rgba(14, 36, 68, 0.22);
}
.hvac-row:nth-child(even) {
background: rgba(22, 56, 104, 0.28);
}
.hvac-row:last-child { border-bottom: none; }
.hvac-row span { min-width: 0; }
.hvac-row b {
color: #fff;
font-weight: 500;
font-family: var(--font-num);
text-align: right;
justify-self: end;
}
.hvac-row b.green { color: var(--green); }
.hvac-row b.cyan { color: var(--cyan-lt); }
.cam-grid {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: grid; height: auto !important;
grid-template-columns: 1fr 1fr;
grid-template-rows: repeat(3, 1fr);
gap: 5px;
}
.cam {
position: relative; min-height: 0; height: 100%;
border-radius: 6px; overflow: hidden;
border: 1px solid rgba(62,200,255,.25); background: #04101f;
}
.cam img { width: 100%; height: 100%; object-fit: cover; }
.cam :deep(.cam-corner),
.cam :deep(.corner) {
position: absolute; left: 6px; top: 6px; width: 22px; height: 22px;
}
.cam-name {
position: absolute; right: 6px; top: 6px; font-size: 10px;
background: rgba(4,16,31,.6); padding: 1px 6px; border-radius: 3px;
} }
</style> </style>
...@@ -5,8 +5,10 @@ interface ImportMetaEnv { ...@@ -5,8 +5,10 @@ interface ImportMetaEnv {
readonly VITE_WS_URL: string readonly VITE_WS_URL: string
readonly VITE_VIEWPORT_MODE: 'glb' | 'ue-stream' | 'rtengine' readonly VITE_VIEWPORT_MODE: 'glb' | 'ue-stream' | 'rtengine'
readonly VITE_UE_STREAM_URL: string readonly VITE_UE_STREAM_URL: string
/** RTEngineHD 云渲染页面,如 http://192.168.32.121:6088/gis.html */ /** RTEngineHD 云渲染页(推荐同域 /rtengine/gis.html?scene=…) */
readonly VITE_RTENGINE_URL: string readonly VITE_RTENGINE_URL: string
/** /rtengine、/peer-stream、/designer 反代上游,如 http://192.168.32.121:6088 */
readonly VITE_RTENGINE_PROXY_TARGET: string
} }
interface ImportMeta { interface ImportMeta {
......
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'node:path' import path from 'node:path'
import type { ProxyOptions } from 'vite'
export default defineConfig({ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, path.resolve(__dirname, '..'), '')
const rtengineTarget =
env.VITE_RTENGINE_PROXY_TARGET ||
process.env.VITE_RTENGINE_PROXY_TARGET ||
'http://192.168.32.121:6088'
/** 同域反代 RTEngine:页面走 /rtengine,信令/场景 API 走根路径(引擎写死绝对路径) */
const rtengineProxy = (extra: ProxyOptions = {}): ProxyOptions => ({
target: rtengineTarget,
changeOrigin: true,
ws: true,
secure: false,
...extra,
})
return {
plugins: [ plugins: [
vue({ vue({
template: { template: {
...@@ -25,6 +42,15 @@ export default defineConfig({ ...@@ -25,6 +42,15 @@ export default defineConfig({
target: 'ws://localhost:3001', target: 'ws://localhost:3001',
ws: true, ws: true,
}, },
// iframe 页与相对资源:/rtengine/gis.html → 6088/gis.html
'/rtengine': rtengineProxy({
rewrite: (p) => p.replace(/^\/rtengine/, '') || '/',
}),
// dte.js WebSocket 信令(绝对路径 /peer-stream)
'/peer-stream': rtengineProxy(),
// 场景书签 / 嵌入 API(绝对路径 /designer/...)
'/designer': rtengineProxy(),
}, },
}, },
}
}) })
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