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=
VITE_WS_URL=
# 三维视口:glb | ue-stream | rtengine(RTEngineHD 云渲染 iframe)
VITE_VIEWPORT_MODE=rtengine
VITE_RTENGINE_URL=http://192.168.32.121:6088/gis.html
VITE_UE_STREAM_URL=http://192.168.32.121:6088/gis.html
# 推荐同域反代(postMessage 同域);开发/Nginx 已代理 /rtengine + /peer-stream + /designer
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
- `DATABASE_URL` PostgreSQL 连接串
- `DATA_MODE=mock|modbus`
- `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 备用地址
- `ACCESS_BRIDGE_URL=` 门禁桥接
......
......@@ -74,8 +74,8 @@ services:
args:
VITE_API_BASE: ""
VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-rtengine}
VITE_RTENGINE_URL: ${VITE_RTENGINE_URL:-http://192.168.32.121:6088/gis.html}
VITE_UE_STREAM_URL: ${VITE_UE_STREAM_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:-/rtengine/gis.html?scene=mvg8x4b453b45f38}
container_name: jh-lab-web
restart: unless-stopped
depends_on:
......
......@@ -8,7 +8,7 @@
|---|---|
| id | 行号(导入时可忽略,平台用 UUID) |
| Name | 点名 |
| RegType | 现场侧类型,如 `3X(Input Register)` |
| RegType | 现场侧类型+功能码。`3X(Input Register)` / `1X` 为只读采集;`0X(Coil Status)-F5` 为写单线圈(控制);`4X(Holding Register)-F6` 为写寄存器(控制);`-F15/-F16` 为多写。**例外**:电表 / 流量计(水)/ 能量计 点表虽常标 `-F6`,点名均为电压/电流/流量/热量等采集量,`Description` 为空,业务上按只读处理 |
| RegAddress | 现场侧地址 |
| DataType | `Short` / `Word` / `Float` / `Double` / `Bit` 等 |
| Modbus ID | 从站号(unit id) |
......@@ -25,8 +25,8 @@
- `gateway_id`:逻辑 Modbus 主机(`.102/.103/.104`)
- `device_id`:所属设备
- `snapshot_path`:映射大屏快照 JSON 路径
- `rw`:`r` / `w` / `rw`
- `energy_kind`:`power` / `water` / `cooling` / null
- `rw`:`r` / `w` / `rw`(由 RegType 推断:含 `-F5/-F6/-F15/-F16` → `w`,周期采集跳过写控点;文件名含「电表|流量计|能量计」或点名为计量量时强制 `r`)
- `energy_kind`:`power` / `water` / `cooling` / null(无独立「水表」CSV,水相关用流量计)
- `enabled`:是否参与采集
- `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 {
std::string itemId;
std::string name;
std::string gatewayId;
std::string regType;
std::string rw = "r"; // r | w | rw;F5/F6 写控点为 w,不参与周期读
int unitId = 1;
int address = 0;
std::string dataType = "Short";
double scale = 0.1;
double bias = 0;
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 {
......@@ -370,10 +393,17 @@ std::vector<PointCfg> parsePointsPayload(const std::string& body) {
p.name = extractString(obj, "name");
p.gatewayId = extractString(obj, "gatewayId");
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.address = extractInt(obj, "modbusAddress", extractInt(obj, "modbus_address", 0));
p.dataType = extractString(obj, "dataType");
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);
if (std::isnan(scaleOverride)) scaleOverride = extractDouble(obj, "scale_override", NAN);
double scale = extractDouble(obj, "scale", 0);
......@@ -506,12 +536,18 @@ void pollOnce() {
for (const auto& h : hosts) {
if (!h.enabled || h.host.empty()) continue;
std::vector<PointCfg> mine;
size_t skippedWrite = 0;
for (const auto& p : points) {
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
<< " points=" << mine.size() << "\n"
<< " points=" << mine.size() << " skipWrite=" << skippedWrite << "\n"
<< std::flush;
bool anyOk = false;
int lastLat = 0;
......@@ -829,6 +865,7 @@ std::string buildSnapshotJson(bool goodOnly) {
bool first = true;
for (const auto& p : g.points) {
if (!p.enabled) continue;
if (!p.readable()) continue; // F5/F6 写控点不进采集快照
const std::string key = p.id.empty() ? p.itemId : p.id;
auto it = g.values.find(key);
double raw = 0;
......@@ -951,9 +988,92 @@ int main(int argc, char** argv) {
res.set_content(o.str(), "application/json; charset=utf-8");
});
svr.Post("/modbus/write", [](const httplib::Request& req, httplib::Response& res) {
std::cout << "[gateway] modbus write: " << req.body << "\n";
res.set_content("{\"ok\":true,\"message\":\"accepted (scaffold)\"}",
std::cout << "[gateway] modbus write: " << req.body << "\n" << std::flush;
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");
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) {
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
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'
import { config } from '../config.js'
import { decodeCsvBuffer, parsePointsCsv, type ParsedPointRow } from './csv.js'
import { defaultAlarmLimits } from './alarmDefaults.js'
import { inferPointRw } from './pointRw.js'
import type { Device, DeviceCategory, DevicePoint, DevicesBundle, Gateway } from './types.js'
function now() {
......@@ -166,6 +167,7 @@ function pointFromRow(
gatewayId: string,
deviceId: string,
t: string,
sourceCsv: string,
): DevicePoint {
return {
id: randomUUID(),
......@@ -189,12 +191,16 @@ function pointFromRow(
maxEngRange: row.maxEngRange,
itemId: row.itemId,
snapshotPath: '',
rw: 'r',
energyKind: /电表|电能|电压|电流|功率|有功/.test(row.name)
rw: inferPointRw(row.regType, 'r', {
name: row.name,
itemId: row.itemId,
sourceCsv,
}),
energyKind: /电表|电能|电压|电流|功率|有功/.test(`${row.name} ${sourceCsv}`)
? 'power'
: /流量计|水/.test(row.name)
: /流量计|水/.test(`${row.name} ${sourceCsv}`)
? 'water'
: /能量计|冷量/.test(row.name)
: /能量计|冷量/.test(`${row.name} ${sourceCsv}`)
? 'cooling'
: null,
scaleOverride: resolveScaleOverride(row),
......@@ -266,7 +272,7 @@ export function buildBundleFromProtocolDir(dir: string): DevicesBundle {
})
}
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'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
import { defaultAlarmLimits } from './alarmDefaults.js'
import { inferPointRw, isReadablePoint } from './pointRw.js'
import {
decodeCsvBuffer,
exportPointsCsv,
......@@ -252,6 +253,31 @@ async function backfillAlarmLimitsPgOnce() {
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) {
for (const g of seed.gateways) {
await query(
......@@ -307,6 +333,7 @@ export async function ensureDevicesSeed() {
)
} else {
await backfillAlarmLimitsPgOnce()
await backfillPointRwPg()
}
return
}
......@@ -314,6 +341,22 @@ export async function ensureDevicesSeed() {
const file = loadFile()
if (file && file.gateways.length && !force && !isStubFridgeOnly(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
}
mem = seedBundle()
......@@ -583,7 +626,7 @@ function pointFromParsed(
maxEngRange: row.maxEngRange,
itemId: row.itemId,
snapshotPath: '',
rw: 'r',
rw: inferPointRw(row.regType, 'r', { name: row.name, itemId: row.itemId }),
energyKind: null,
scaleOverride:
row.scale !== 0
......@@ -628,7 +671,12 @@ export async function createPoint(
maxEngRange: input.maxEngRange ?? 0,
itemId: input.itemId || `${input.name}@${input.modbusAddress}`,
snapshotPath: input.snapshotPath || '',
rw: input.rw || 'r',
rw:
input.rw ||
inferPointRw(input.regType || '', 'r', {
name: input.name,
itemId: input.itemId || '',
}),
energyKind: input.energyKind ?? null,
scaleOverride: input.scaleOverride ?? null,
alarmLow:
......@@ -784,6 +832,7 @@ export async function getBundleForGateway(): Promise<{
return {
pointsVersion,
gateways: gateways.filter((g) => g.enabled && g.kind === 'modbus_host'),
// 下发全量启用点(含写控);网关侧按 rw/regType 决定读或写
points: points.filter((p) => p.enabled),
}
}
......@@ -871,7 +920,7 @@ export async function applyPointTelemetry(samples: PointTelemetrySample[]): Prom
/** Mock/网关旁路:按点位生成可读工程值,便于后台联调 */
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
const t = Date.now()
const samples: PointTelemetrySample[] = points.map((p, i) => {
......
......@@ -17,7 +17,9 @@ import {
updatePoint,
} from '../devices/store.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()
......@@ -185,3 +187,50 @@ devicesRouter.get('/points/export', authRequired, async (req, res) => {
devicesRouter.post('/points/sync-gateway', authRequired, requireRole('admin'), async (_req, res) => {
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) => {
enabled: body.enabled,
ip: body.ip,
remark: body.remark,
scenePoint: body.scenePoint,
})
res.status(201).json({ device })
} catch (e) {
......@@ -128,6 +129,9 @@ videoRouter.patch('/devices/:id', authRequired, requireRole('admin'), (req, res)
enabled: body.enabled,
ip: body.ip,
remark: body.remark,
...(Object.prototype.hasOwnProperty.call(body, 'scenePoint')
? { scenePoint: body.scenePoint }
: {}),
})
res.json({ device })
} catch (e) {
......
......@@ -9,6 +9,15 @@ export type DeviceType = 'ipc' | 'nvr-ch'
export type VideoProtocol = 'sdk' | 'rtsp' | 'isapi'
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 {
id: string
name: string
......@@ -19,6 +28,8 @@ export interface VideoDevice {
enabled: boolean
ip: string
remark: string
/** 三维场景坐标;未打点为 null */
scenePoint: ScenePoint | null
createdAt: string
updatedAt: string
}
......@@ -83,6 +94,7 @@ function seedDevices(): VideoDevice[] {
]
return rows.map((r) => ({
...r,
scenePoint: null as ScenePoint | null,
id: randomUUID(),
createdAt: now,
updatedAt: now,
......@@ -95,6 +107,37 @@ function normalizeProtocol(p: string | undefined): VideoProtocol {
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 {
const base = defaultConfig()
const c = { ...base, ...(raw.config || {}) }
......@@ -102,7 +145,10 @@ function normalizeData(raw: VideoData): VideoData {
c.protocol = normalizeProtocol(c.protocol as string)
c.rtspPort = Number(c.rtspPort) || 554
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() {
......@@ -217,6 +263,7 @@ export function createDevice(input: {
enabled?: boolean
ip?: string
remark?: string
scenePoint?: ScenePoint | null
}): VideoDevice {
const data = load()
if (data.devices.some((d) => d.channel === input.channel)) {
......@@ -233,6 +280,7 @@ export function createDevice(input: {
enabled: input.enabled !== false,
ip: (input.ip || '').trim(),
remark: (input.remark || '').trim(),
scenePoint: normalizeScenePoint(input.scenePoint),
createdAt: now,
updatedAt: now,
}
......@@ -252,6 +300,7 @@ export function updateDevice(
enabled: boolean
ip: string
remark: string
scenePoint: ScenePoint | null
}>,
): VideoDevice {
const data = load()
......@@ -271,6 +320,10 @@ export function updateDevice(
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.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()
save(data)
return data.devices[idx]
......
......@@ -12,8 +12,8 @@ WORKDIR /app/web
ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=http://192.168.32.121:6088/gis.html
ARG VITE_UE_STREAM_URL=http://192.168.32.121:6088/gis.html
ARG VITE_RTENGINE_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
ARG VITE_UE_STREAM_URL=/rtengine/gis.html?scene=mvg8x4b453b45f38
ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_RTENGINE_URL=$VITE_RTENGINE_URL \
......
......@@ -24,6 +24,51 @@ server {
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 / {
try_files $uri $uri/ /index.html;
}
......
......@@ -68,9 +68,12 @@ function alarmIcon(level: string) {
</template>
<style scoped>
/* 固定展示 3 条高度:每条 52px + 间距 8px → 3*52 + 2*8 = 172 */
/* 默认展示约 3 条;父级更高时可撑满 */
.alarm-viewport {
height: 172px;
flex: 1;
min-height: 120px;
max-height: 172px;
height: 100%;
overflow: hidden;
position: relative;
mask-image: linear-gradient(180deg, transparent 0%, #000 8%, #000 92%, transparent 100%);
......
<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>
......@@ -16,3 +16,22 @@ export const PROTOCOL_ROOMS = [
] as const
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()
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'],
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: [
'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',
......@@ -33,6 +37,13 @@ const navItems = computed(() =>
icon: 'devices',
},
{
to: '/admin/cameras',
label: '摄像头管理',
hint: '列表 / 三维打点',
tone: 'accent' as const,
icon: 'cameras',
},
{
to: '/admin/users',
label: '用户管理',
hint: '账号 / 角色',
......
......@@ -6,7 +6,8 @@ import { useRealtimeStore } from '@/stores/realtime'
import { useClock } from '@/composables/useClock'
import { useMacauWeather } from '@/composables/useMacauWeather'
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 realtime = useRealtimeStore()
......@@ -41,48 +42,45 @@ function goAdmin() {
</script>
<template>
<div class="stage" :style="style">
<header class="screen-header">
<div class="brand">
<AppIcon name="logo" class-name="logo" />
<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 class="stage stage--immersive" :style="style">
<!-- 底层:RTEngine / 三维场景铺满 -->
<div class="scene-layer">
<SceneViewport layout="fill" />
</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 class="ui-layer">
<ScreenHeader>
<template #left>
<div class="weather-wrap">
<span class="place">{{ place }}</span>
<span class="temp">{{ temp }}</span>
<span class="w-text">{{ weatherText }}</span>
<span class="humid">RH:{{ humidity }}</span>
</div>
</template>
<template #right>
<div class="clock-wrap">
<span class="time">{{ time }}</span>
<span class="date">{{ date }}</span>
</div>
<div class="hdr-status">
<div class="alarm-badge" @click="router.push('/dashboard/overview')">
<span class="dot red" />
<span>告警 {{ realtime.alarmCount }}</span>
</div>
<button class="tops-logout admin-entry" type="button" @click="goAdmin">管理</button>
</div>
</header>
</template>
</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">
{{ item.label }}
</RouterLink>
......@@ -91,21 +89,143 @@ function goAdmin() {
<span class="dot" :class="{ red: !realtime.connected }" />
<span>
{{ realtime.connected ? '实时链路在线' : '实时链路断开' }}
· 设备 {{ realtime.snapshot.online.devicesOnline }}/{{ realtime.snapshot.online.devicesTotal }}
· 设备 {{ realtime.snapshot.online.devicesOnline }}/{{
realtime.snapshot.online.devicesTotal
}}
</span>
</div>
</nav>
<main class="screen-main">
<main class="screen-main screen-main--float">
<RouterView />
</main>
</div>
</div>
</template>
<style scoped>
.brand :deep(.logo) {
width: 34px;
height: 34px;
.stage--immersive {
background: #00060f;
}
.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 {
font-size: 12px !important;
......
......@@ -110,6 +110,12 @@ const router = createRouter({
meta: { title: '设备管理', requiresAuth: true },
},
{
path: 'cameras',
name: 'cameras',
component: () => import('@/views/cameras/CamerasView.vue'),
meta: { title: '摄像头管理', requiresAuth: true },
},
{
path: 'users',
name: 'users',
component: () => import('@/views/users/UserManageView.vue'),
......
......@@ -6,26 +6,13 @@
display: flex;
flex-direction: column;
overflow: hidden;
background:
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%);
background: #00060f;
color: var(--text);
font-family: var(--body-font);
}
.stage::before {
content: '';
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; }
content: none;
}
.screen-header {
position: relative;
......@@ -314,7 +301,16 @@
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; }
.stub-box {
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 {
--screen-bg: #081018;
--panel-bg: rgba(8, 16, 24, 0.92);
--panel-border: rgba(61, 190, 255, 0.28);
--panel-title: #e8f4ff;
--panel-muted: #8ea3b8;
--text: #d5e6f2;
--accent: #3dbeff;
--cyan: #3dbeff;
--alert: #e25b5b;
--ok: #3cba7a;
--warn: #e0a04a;
--title-font: Rajdhani, 'PingFang SC', 'Microsoft YaHei', sans-serif;
--body-font: Rajdhani, 'PingFang SC', 'Microsoft YaHei', sans-serif;
--screen-bg: #000b1d;
--panel-bg: rgba(0, 18, 42, 0.55);
--panel-border: rgba(0, 247, 255, 0.22);
--panel-title: #f4feff;
--panel-muted: #7eb6d6;
--text: #d7f4ff;
--accent: #00f7ff;
--cyan: #00f7ff;
--alert: #ff4d4f;
--ok: #52c41a;
--warn: #faad14;
--title-font: 'AlibabaPuHuiTi-3-115-Black', 'Noto Sans SC', 'PingFang SC', sans-serif;
--body-font: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--digital-font: 'Share Tech Mono', ui-monospace, monospace;
/* Backward-compatible aliases used by existing views */
......
......@@ -4,6 +4,14 @@ export type DeviceType = 'ipc' | 'nvr-ch'
export type VideoProtocol = 'sdk' | 'rtsp' | 'isapi'
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 {
id: string
name: string
......@@ -14,6 +22,7 @@ export interface VideoDevice {
enabled: boolean
ip: string
remark: string
scenePoint?: ScenePoint | null
createdAt: 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>
......@@ -5,8 +5,10 @@ interface ImportMetaEnv {
readonly VITE_WS_URL: string
readonly VITE_VIEWPORT_MODE: 'glb' | 'ue-stream' | 'rtengine'
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
/** /rtengine、/peer-stream、/designer 反代上游,如 http://192.168.32.121:6088 */
readonly VITE_RTENGINE_PROXY_TARGET: string
}
interface ImportMeta {
......
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
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: [
vue({
template: {
......@@ -25,6 +42,15 @@ export default defineConfig({
target: 'ws://localhost:3001',
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