Commit f2d7804e by James Yuan

feat: WebRTC 预览与本机 Docker 媒体面部署

定稿大屏/后台视频走 WHEP;Compose 增加 MediaMTX,去掉临时 Windows 网关依赖,并完善网关契约与设备管理相关页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 323abebd
......@@ -17,8 +17,13 @@ DATA_MODE=mock
# DATA_MODE=gateway
# DATA_MODE=modbus
# 单一 Windows 协议网关(Modbus + HCNetSDK)
GATEWAY_URL=http://127.0.0.1:38080
# Windows 协议网关(现场采集机;本机 Docker 可留空)
GATEWAY_URL=
# WebRTC WHEP 媒体面:本机 Compose 默认 mediamtx;有 GATEWAY 且无本项时走网关 /webrtc/whep/:id
WEBRTC_WHEP_BASE=http://mediamtx:8889
WEBRTC_ICE_HOST=127.0.0.1
WEB_PORT=18080
# 现场点位 CSV 目录(默认:仓库旁「设备对接/…/澳门镜湖医院协议20261019」)
# DEVICE_POINTS_DIR=/absolute/path/to/protocol-csvs
......
......@@ -2,23 +2,21 @@
Vue 3 大屏 + Node 采集服务 + PostgreSQL 本地落库。一期使用 demo GLB 模型,预留 UE 推流;自控数据 Mock/Modbus;门禁预留海康 HCNetSDK 桥接;含用户管理、RBAC 与基础计费。
## 容器化部署(推荐)
依赖:Docker / Docker Compose。
## 容器化部署(推荐 · 本机 Docker)
```bash
cd dashboard
cp .env.example .env
# 按需修改 JWT_SECRET、数据库密码等
cp .env.example .env # 已有 .env 可跳过
docker compose up -d --build
```
| 服务 | 地址 | 说明 |
|---|---|---|
| 大屏 Web | http://localhost:8080 | Nginx 静态资源,反代 `/api`、`/ws` |
| API | http://localhost:3001 | Node 采集与业务服务 |
| PostgreSQL | localhost:5432 | 用户、计费、全部采集快照 |
| 大屏 Web | http://localhost:18080 | Nginx,反代 `/api`、`/ws` |
| API | http://localhost:3001 | Node 业务服务 |
| PostgreSQL | localhost:5434 | 用户、计费、采集落库 |
| MediaMTX | :8554 RTSP / :8889 WebRTC(WHEP) | 视频媒体面 |
演示账号:`admin` / `Admin@123`。视频走 WebRTC,见 `docs/video-webrtc.md`。
停止:
......@@ -89,3 +87,5 @@ npm run dev
- `docs/` 架构、网关 API、点位与门禁对接说明
设备/点位:登录管理员 → `/devices`;导入现场 CSV 后可「下发网关」。`GATEWAY_URL` 指向 Windows 网关(默认 `http://127.0.0.1:38080`)。
视频实况:**WebRTC(WHEP)** — 见 [`docs/video-webrtc.md`](docs/video-webrtc.md);大屏「视频监控」使用 `WebRtcPlayer`。
......@@ -10,13 +10,29 @@ services:
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
- "${POSTGRES_PORT:-5434}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
# WebRTC / WHEP 媒体面(本机 Docker;可拉 RTSP 再给浏览器)
mediamtx:
image: bluenviron/mediamtx:1
container_name: jh-lab-mediamtx
restart: unless-stopped
environment:
# 浏览器在本机访问时,ICE 通告宿主机可达地址
MTX_WEBRTCICEHOSTNAT1TO1IPS: ${WEBRTC_ICE_HOST:-127.0.0.1}
MTX_WEBRTCICETCPMUTUALLISTENINGPORT: "8189"
ports:
- "${MEDIAMTX_RTSP_PORT:-8554}:8554"
- "${MEDIAMTX_HLS_PORT:-8888}:8888"
- "${MEDIAMTX_WEBRTC_PORT:-8889}:8889"
- "${MEDIAMTX_WEBRTC_UDP:-8189}:8189/udp"
- "${MEDIAMTX_WEBRTC_TCP:-8189}:8189"
server:
build:
context: .
......@@ -26,16 +42,22 @@ services:
depends_on:
db:
condition: service_healthy
mediamtx:
condition: service_started
environment:
PORT: 3001
DATABASE_URL: postgres://${POSTGRES_USER:-jh_lab}:${POSTGRES_PASSWORD:-jh_lab_pass}@db:5432/${POSTGRES_DB:-jh_lab}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-12h}
DATA_MODE: ${DATA_MODE:-mock}
# 临时 Windows 网关已弃用;本机无网关时留空
GATEWAY_URL: ${GATEWAY_URL:-}
# 容器内走服务名;浏览器 ICE 仍用宿主机端口 8889
WEBRTC_WHEP_BASE: ${WEBRTC_WHEP_BASE:-http://mediamtx:8889}
MODBUS_PAU1_HOST: ${MODBUS_PAU1_HOST:-192.168.10.102}
MODBUS_PAU2_HOST: ${MODBUS_PAU2_HOST:-192.168.10.103}
MODBUS_ROOM_HOST: ${MODBUS_ROOM_HOST:-192.168.10.104}
MODBUS_FRIDGE_HOST: ${MODBUS_FRIDGE_HOST:-192.168.10.105}
MODBUS_FRIDGE_HOST: ${MODBUS_FRIDGE_HOST:-192.168.10.104}
MODBUS_PORT: ${MODBUS_PORT:-502}
MODBUS_POLL_MS: ${MODBUS_POLL_MS:-2000}
ACCESS_BRIDGE_URL: ${ACCESS_BRIDGE_URL:-}
......@@ -58,7 +80,7 @@ services:
depends_on:
- server
ports:
- "${WEB_PORT:-8080}:80"
- "${WEB_PORT:-18080}:80"
volumes:
pgdata:
......@@ -15,7 +15,7 @@
| 网络/IP | 监听地址、端口、平台 URL、Modbus 主机 IP |
| 设备列表 | Modbus / 摄像头 / 门禁 / NVR |
| 设备配置 | 增改删海康设备(IP/端口/账号/通道) |
| 摄像头预览 | JPEG/SVG 抓图通道(HCNetSDK 接入前为占位图) |
| 摄像头预览 | 管理台缩略图(JPEG);大屏/后台实况统一走 **WebRTC** |
管理 API 前缀:`/admin/api/*`(需 `Authorization: Bearer <token>`)。
......@@ -68,6 +68,18 @@ Body:全量点位数组(与平台 `device_points` 导出字段一致),
`WS /telemetry` — 推送同上结构的增量或全量帧(`type: "telemetry"`)。
## WebRTC(WHEP,已定稿)
大屏/后台实况统一走 WebRTC。浏览器经 Node `POST /api/video/devices/:id/whep` 反代到网关:
`POST /webrtc/whep/:streamId`
- Request:`Content-Type: application/sdp`(offer)
- 成功:`201` + `application/sdp`(answer),可选 `Location` 资源 URL
- 媒体未就绪:`503` JSON `{ ok:false, message, streamId }`
详见 [`video-webrtc.md`](./video-webrtc.md)。
## Modbus 写
`POST /modbus/write`
......
# 视频预览:WebRTC 方案(已定稿)
大屏与后台**统一采用 WebRTC** 查看各路监控,不在浏览器直连 RTSP / HCNetSDK。
## 架构
```mermaid
flowchart LR
NVR["海康NVR_IPC"]
Gw["Windows协议网关"]
Media["WebRTC媒体面_WHEP"]
Node["Node平台"]
Vue["大屏_后台Vue"]
NVR -->|"RTSP或HCNetSDK取流"| Gw
Gw --> Media
Media -->|"SDP Answer WHEP"| Node
Vue -->|"POST Offer + JWT"| Node
Node --> Media
Vue -->|"RTCPeerConnection"| Media
```
| 层级 | 职责 |
|---|---|
| 现场 | NVR/IPC 出 RTSP(或 SDK RealPlay) |
| Windows 网关 | 登录设备、维护通道;对接媒体面推流 / 代理 WHEP |
| 媒体面 | RTSP→WebRTC(推荐同机部署 **ZLMediaKit**,或后续嵌入 libwebrtc) |
| Node | JWT 鉴权、按设备解析 `streamId`、反代 WHEP,避免浏览器跨域 |
| Vue | `RTCPeerConnection` + WHEP(`POST` SDP offer,收 answer) |
信令采用 **WHEP**(WebRTC-HTTP Egress Protocol):HTTP 交换 SDP,无需单独信令 WS。
## URL 约定
| 用途 | 路径 |
|---|---|
| 平台(前端调用) | `POST /api/video/devices/:id/whep` Body: `application/sdp` |
| 网关(Node 反代) | `POST {GATEWAY_URL}/webrtc/whep/:streamId` |
| 流 ID | 默认 `cam-{设备id}` 或通道 `nvr-{channel}`;可在网关 `cameras[].streamId` 覆盖 |
环境变量:
- `GATEWAY_URL`:协议网关
- `WEBRTC_WHEP_BASE`(可选):若媒体面独立于网关(如 ZLM `http://host:80`),Node 可直连该基址;为空则走 `GATEWAY_URL`
## 前端行为
1. 选中通道 → 创建 `RTCPeerConnection`(recvonly)
2. `createOffer` → `POST /api/video/devices/:id/whep`
3. `setRemoteDescription(answer)`
4. `ontrack` → 绑定到 `<video autoplay playsinline>`
5. 切换通道 / 离开页面 → `pc.close()`,必要时 `DELETE` WHEP 资源
宫格多路时每路独立 PC;优先 NVR **子码流** 控制带宽。
## 与 JPEG 预览关系
- WebRTC:连续实况(主路径)
- `GET .../preview.jpg`:门禁联动快照、网关管理台缩略图、WebRTC 未就绪时的降级
## 本机 Docker(推荐开发)
```bash
docker compose up -d --build
```
| 服务 | 地址 |
|---|---|
| 大屏 Web | http://localhost:18080 |
| API | http://localhost:3001 |
| MediaMTX WHEP | http://localhost:8889/{streamId}/whep |
| MediaMTX RTSP | rtsp://localhost:8554/{streamId} |
`WEBRTC_WHEP_BASE=http://mediamtx:8889`(容器内)。向 MediaMTX 推流或配置 RTSP 源后,大屏即可 WebRTC 播放。
## 现场采集机(Windows 网关)
1. 网关或旁路媒体服务提供 WHEP;或本机仍用 Docker MediaMTX,网关只负责 Modbus/门禁
2. 摄像头 RTSP 拉进 MediaMTX 流名与 `cam-{deviceId}` / `nvr-{channel}` 对齐
3. 浏览器需能访问 ICE/UDP(`8189`)
......@@ -571,6 +571,23 @@ int main(int argc, char** argv) {
res.set_content("{\"ok\":true,\"message\":\"accepted (scaffold)\"}",
"application/json; charset=utf-8");
});
/**
* WebRTC WHEP(Egress):浏览器 POST application/sdp offer
* 当前返回 503 说明;接入 ZLMediaKit / 内嵌 WebRTC 后在此回 answer SDP
*/
svr.Post(R"(/webrtc/whep/([^/]+))", [](const httplib::Request& req, httplib::Response& res) {
const std::string streamId = req.matches[1];
std::cout << "[gateway] WHEP offer streamId=" << streamId
<< " sdpBytes=" << req.body.size() << "\n";
res.status = 503;
res.set_content(
"{\"ok\":false,\"message\":\"WebRTC 媒体面未就绪:请在采集机部署 ZLMediaKit(或网关内嵌 "
"WebRTC)并配置 RTSP 拉流。streamId=" +
streamId + "\",\"streamId\":\"" + jsonEscape(streamId) + "\"}",
"application/json; charset=utf-8");
});
svr.Get("/doors", [](const httplib::Request&, httplib::Response& res) {
std::lock_guard<std::mutex> lock(g.mu);
std::ostringstream o;
......
......@@ -96,7 +96,8 @@
<section class="panel hidden" id="page-cameras">
<h2>摄像头预览</h2>
<p style="color:var(--muted);font-size:13px;margin-top:0">
当前为网关 JPEG 抓图通道(HCNetSDK 接入前显示占位图;接入后自动换成实况截帧)。
管理台缩略图为抓图通道;大屏/平台后台实况统一使用 <strong>WebRTC(WHEP)</strong>。
媒体面就绪前此处仍显示占位图。
</p>
<div class="preview-grid" id="previewGrid"></div>
<div class="actions">
......
......@@ -15,6 +15,11 @@ export const config = {
collectRetentionDays: Number(process.env.COLLECT_RETENTION_DAYS || 90),
/** 单一 Windows 协议网关 */
gatewayUrl: (process.env.GATEWAY_URL || '').replace(/\/$/, ''),
/**
* WebRTC WHEP 基址(可选)。
* 为空则使用 GATEWAY_URL;若媒体面为独立 ZLMediaKit,可设为 http://host:80
*/
webrtcWhepBase: (process.env.WEBRTC_WHEP_BASE || '').replace(/\/$/, ''),
modbus: {
pau1: process.env.MODBUS_PAU1_HOST || '192.168.10.102',
pau2: process.env.MODBUS_PAU2_HOST || '192.168.10.103',
......
......@@ -5,6 +5,69 @@ export function resolveGatewayBase(): string {
return (config.gatewayUrl || config.accessBridgeUrl || '').replace(/\/$/, '')
}
export function resolveWhepBase(): string {
return (config.webrtcWhepBase || resolveGatewayBase()).replace(/\/$/, '')
}
/** 将平台设备映射为媒体流 ID */
export function deviceStreamId(device: { id: string; channel?: number; deviceType?: string }): string {
if (device.deviceType === 'nvr-ch' && device.channel) return `nvr-${device.channel}`
return `cam-${device.id}`
}
/**
* WHEP:把浏览器 SDP offer 转到网关/媒体面,返回 answer SDP 与 Location
*/
export async function proxyWhepOffer(
streamId: string,
offerSdp: string,
): Promise<{ ok: boolean; status: number; answerSdp: string; location?: string; message?: string }> {
const base = resolveWhepBase()
if (!base) {
return {
ok: false,
status: 503,
answerSdp: '',
message: '未配置 GATEWAY_URL / WEBRTC_WHEP_BASE,无法建立 WebRTC',
}
}
// MediaMTX: /{stream}/whep ;Windows 网关: /webrtc/whep/{stream}
const url = config.webrtcWhepBase
? `${base}/${encodeURIComponent(streamId)}/whep`
: `${base}/webrtc/whep/${encodeURIComponent(streamId)}`
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
Accept: 'application/sdp',
},
body: offerSdp,
signal: AbortSignal.timeout(15000),
})
const answerSdp = await res.text()
const location = res.headers.get('Location') || undefined
if (!res.ok) {
let message = answerSdp || `媒体面 HTTP ${res.status}`
try {
const j = JSON.parse(answerSdp) as { message?: string }
if (j.message) message = j.message
} catch {
/* ignore */
}
return { ok: false, status: res.status, answerSdp, location, message }
}
return { ok: true, status: res.status, answerSdp, location }
} catch (e) {
return {
ok: false,
status: 502,
answerSdp: '',
message: e instanceof Error ? e.message : 'WHEP 代理失败',
}
}
}
export async function fetchGatewayHealth(): Promise<{
ok: boolean
raw?: unknown
......
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { deviceStreamId, proxyWhepOffer } from '../gateway/client.js'
import {
createDevice,
deleteDevice,
......@@ -142,3 +143,60 @@ videoRouter.delete('/devices/:id', authRequired, requireRole('admin'), (req, res
res.status(400).json({ message: e instanceof Error ? e.message : '删除失败' })
}
})
/**
* WebRTC WHEP:浏览器 POST application/sdp(offer),返回 answer SDP
* 媒体面由 Windows 网关 / ZLMediaKit 提供
*/
videoRouter.post(
'/devices/:id/whep',
authRequired,
expressSdp,
async (req, res) => {
const devices = listDevices()
const device = devices.find((d) => d.id === req.params.id)
if (!device) {
res.status(404).json({ ok: false, message: '设备不存在' })
return
}
if (!device.enabled) {
res.status(400).json({ ok: false, message: '设备未启用' })
return
}
const offerSdp = typeof req.body === 'string' ? req.body : String(req.body || '')
if (!offerSdp.includes('v=0')) {
res.status(400).json({ ok: false, message: '请提交 SDP offer(Content-Type: application/sdp)' })
return
}
const streamId = deviceStreamId(device)
const result = await proxyWhepOffer(streamId, offerSdp)
if (!result.ok) {
res.status(result.status || 502).json({
ok: false,
message: result.message || 'WebRTC 协商失败',
streamId,
})
return
}
if (result.location) res.setHeader('Location', result.location)
res.status(201)
res.type('application/sdp')
res.send(result.answerSdp)
},
)
/** 解析 text/plain 与 application/sdp body */
function expressSdp(req: import('express').Request, res: import('express').Response, next: import('express').NextFunction) {
const ct = String(req.headers['content-type'] || '')
if (ct.includes('application/sdp') || ct.includes('text/plain')) {
const chunks: Buffer[] = []
req.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)))
req.on('end', () => {
req.body = Buffer.concat(chunks).toString('utf8')
next()
})
req.on('error', next)
return
}
next()
}
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
deviceId: string
/** 覆盖默认 WHEP 路径 */
whepPath?: string
}>()
const auth = useAuthStore()
const videoEl = ref<HTMLVideoElement | null>(null)
const status = ref<'idle' | 'connecting' | 'playing' | 'error'>('idle')
const errorMsg = ref('')
let pc: RTCPeerConnection | null = null
let whepResource: string | null = null
async function stop() {
try {
if (whepResource && auth.token) {
await fetch(whepResource, {
method: 'DELETE',
headers: { Authorization: `Bearer ${auth.token}` },
}).catch(() => undefined)
}
} finally {
whepResource = null
pc?.close()
pc = null
if (videoEl.value) videoEl.value.srcObject = null
status.value = 'idle'
}
}
async function start() {
await stop()
if (!props.deviceId) return
status.value = 'connecting'
errorMsg.value = ''
pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
})
pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' })
pc.ontrack = (ev) => {
if (!videoEl.value) return
videoEl.value.srcObject = ev.streams[0] || new MediaStream([ev.track])
void videoEl.value.play().catch(() => undefined)
status.value = 'playing'
}
pc.onconnectionstatechange = () => {
if (!pc) return
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
status.value = 'error'
errorMsg.value = 'WebRTC 连接中断'
}
}
try {
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
await waitIceGathering(pc)
const path = props.whepPath || `/api/video/devices/${encodeURIComponent(props.deviceId)}/whep`
const res = await fetch(path, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
Accept: 'application/sdp',
Authorization: `Bearer ${auth.token}`,
},
body: pc.localDescription?.sdp || offer.sdp || '',
})
const loc = res.headers.get('Location')
if (loc) whepResource = loc.startsWith('http') ? loc : loc
const answerSdp = await res.text()
if (!res.ok) {
let msg = answerSdp || `WHEP HTTP ${res.status}`
try {
const j = JSON.parse(answerSdp) as { message?: string }
if (j.message) msg = j.message
} catch {
/* plain sdp or text */
}
throw new Error(msg)
}
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp })
} catch (e) {
status.value = 'error'
errorMsg.value = e instanceof Error ? e.message : 'WebRTC 启动失败'
await stop()
status.value = 'error'
}
}
function waitIceGathering(conn: RTCPeerConnection) {
if (conn.iceGatheringState === 'complete') return Promise.resolve()
return new Promise<void>((resolve) => {
const t = window.setTimeout(() => resolve(), 2000)
conn.onicegatheringstatechange = () => {
if (conn.iceGatheringState === 'complete') {
window.clearTimeout(t)
resolve()
}
}
})
}
watch(
() => props.deviceId,
() => {
void start()
},
{ immediate: true },
)
onBeforeUnmount(() => {
void stop()
})
defineExpose({ restart: start, stop })
</script>
<template>
<div class="webrtc-player" :class="status">
<video ref="videoEl" class="video" autoplay playsinline muted />
<div v-if="status !== 'playing'" class="overlay">
<template v-if="status === 'connecting'">正在建立 WebRTC…</template>
<template v-else-if="status === 'error'">
<div>WebRTC 未就绪</div>
<div class="sub">{{ errorMsg }}</div>
<button type="button" class="btn" @click="start">重试</button>
</template>
<template v-else>空闲</template>
</div>
<div v-else class="live-tag"><i /><span>WebRTC · LIVE</span></div>
</div>
</template>
<style scoped>
.webrtc-player {
position: relative;
width: 100%;
height: 100%;
min-height: 280px;
background: #050a14;
border-radius: 8px;
overflow: hidden;
}
.video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #c9d7f0;
font-size: 14px;
background: rgba(5, 10, 20, 0.72);
text-align: center;
padding: 16px;
}
.sub {
font-size: 12px;
color: #8fa3c4;
max-width: 80%;
line-height: 1.5;
word-break: break-all;
}
.btn {
border: 1px solid rgba(61, 139, 253, 0.45);
background: rgba(61, 139, 253, 0.2);
color: #fff;
border-radius: 6px;
padding: 6px 14px;
cursor: pointer;
}
.live-tag {
position: absolute;
top: 12px;
left: 12px;
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
color: #9dffc0;
}
.live-tag i {
width: 7px;
height: 7px;
border-radius: 50%;
background: #3dd68c;
box-shadow: 0 0 8px #3dd68c;
}
</style>
......@@ -7,6 +7,10 @@ import type { Role, User } from '@/types'
const auth = useAuthStore()
const users = ref<User[]>([])
const error = ref('')
const message = ref('')
const showCreate = ref(false)
const saving = ref(false)
const form = reactive({
username: '',
displayName: '',
......@@ -14,6 +18,24 @@ const form = reactive({
role: 'viewer' as Role,
})
function resetForm() {
form.username = ''
form.displayName = ''
form.password = ''
form.role = 'viewer'
error.value = ''
}
function openCreate() {
resetForm()
showCreate.value = true
}
function closeCreate() {
showCreate.value = false
error.value = ''
}
async function load() {
const data = await api<{ users: User[] }>('/api/users', { token: auth.token })
users.value = data.users
......@@ -21,19 +43,21 @@ async function load() {
async function create() {
error.value = ''
saving.value = true
try {
await api('/api/users', {
method: 'POST',
token: auth.token,
body: JSON.stringify(form),
})
form.username = ''
form.displayName = ''
form.password = ''
form.role = 'viewer'
message.value = `已创建用户 ${form.username}`
closeCreate()
resetForm()
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
......@@ -56,6 +80,7 @@ async function remove(u: User) {
error.value = ''
try {
await api(`/api/users/${u.id}`, { method: 'DELETE', token: auth.token })
message.value = `已删除 ${u.username}`
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
......@@ -66,37 +91,33 @@ onMounted(load)
</script>
<template>
<div class="page-fill">
<section class="panel">
<div class="page-fill admin-list-page">
<section class="panel list-panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>新建用户</h3></div>
<div class="badge">仅管理员</div>
</div>
<div class="form-grid">
<div class="field"><label>用户名</label><input v-model="form.username" /></div>
<div class="field"><label>显示名</label><input v-model="form.displayName" /></div>
<div class="field"><label>密码</label><input v-model="form.password" type="password" /></div>
<div class="field">
<label>角色</label>
<select v-model="form.role">
<option value="viewer">只读 (viewer)</option>
<option value="admin">管理员 (admin)</option>
</select>
<div class="panel-title">
<div class="vbar" />
<h3>用户列表({{ users.length }})</h3>
</div>
<div class="head-actions">
<div class="badge">仅管理员</div>
<button class="btn" type="button" @click="openCreate">新增用户</button>
</div>
<div style="margin-top:12px; display:flex; gap:10px; align-items:center">
<button class="btn" type="button" @click="create">创建</button>
<span v-if="error" style="color:#ff9b9b;font-size:12px">{{ error }}</span>
</div>
</section>
<section class="panel" style="flex:1; overflow:auto">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>用户列表</h3></div>
</div>
<p v-if="message" class="msg ok">{{ message }}</p>
<p v-if="error && !showCreate" class="msg err">{{ error }}</p>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>最近登录</th><th>操作</th></tr>
<tr>
<th>用户名</th>
<th>显示名</th>
<th>角色</th>
<th>状态</th>
<th>最近登录</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
......@@ -104,14 +125,96 @@ onMounted(load)
<td>{{ u.displayName }}</td>
<td>{{ u.role === 'admin' ? '管理员' : '只读' }}</td>
<td>{{ u.enabled ? '启用' : '禁用' }}</td>
<td>{{ u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString('zh-CN') : '-' }}</td>
<td>
<button class="btn ghost" @click="toggle(u)">{{ u.enabled ? '禁用' : '启用' }}</button>
<button class="btn danger" @click="remove(u)">删除</button>
<td>{{ u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString('zh-CN') : '—' }}</td>
<td class="ops">
<button class="btn ghost" type="button" @click="toggle(u)">
{{ u.enabled ? '禁用' : '启用' }}
</button>
<button class="btn danger" type="button" @click="remove(u)">删除</button>
</td>
</tr>
<tr v-if="!users.length">
<td colspan="6" class="empty">暂无用户,点击右上角「新增用户」</td>
</tr>
</tbody>
</table>
</div>
</section>
<div v-if="showCreate" class="modal-mask" @click.self="closeCreate">
<div class="modal panel">
<div class="panel-head">
<div class="panel-title"><div class="vbar" /><h3>新增用户</h3></div>
<button class="btn ghost" type="button" @click="closeCreate">关闭</button>
</div>
<div class="form-grid">
<div class="field"><label>用户名</label><input v-model="form.username" autocomplete="off" /></div>
<div class="field"><label>显示名</label><input v-model="form.displayName" /></div>
<div class="field"><label>密码</label><input v-model="form.password" type="password" /></div>
<div class="field">
<label>角色</label>
<select v-model="form.role">
<option value="viewer">只读 (viewer)</option>
<option value="admin">管理员 (admin)</option>
</select>
</div>
</div>
<p v-if="error" class="msg err">{{ error }}</p>
<div class="modal-actions">
<button class="btn" type="button" :disabled="saving" @click="create">
{{ saving ? '创建中…' : '创建' }}
</button>
<button class="btn ghost" type="button" @click="closeCreate">取消</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.admin-list-page { min-height: 0; }
.list-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
}
.ops { display: flex; gap: 6px; }
.empty {
text-align: center;
color: var(--panel-muted);
padding: 28px 10px !important;
}
.msg.ok { color: var(--ok); font-size: 13px; margin-bottom: 8px; }
.msg.err { color: #ffb0b0; font-size: 13px; margin: 8px 0; }
.modal-mask {
position: fixed;
inset: 0;
background: rgba(4, 8, 14, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
width: min(560px, 92vw);
max-height: 90vh;
overflow: auto;
}
.modal-actions {
margin-top: 14px;
display: flex;
gap: 10px;
}
</style>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { api } from '@/api/http'
import WebRtcPlayer from '@/components/video/WebRtcPlayer.vue'
import { useAuthStore } from '@/stores/auth'
import type {
DeviceStatus,
......@@ -411,29 +412,17 @@ onMounted(loadAll)
CH{{ selected.channel }} · {{ statusLabel[selected.status] }}
</div>
</div>
<div class="stage" :class="selected?.status || 'offline'">
<div class="scan" />
<div class="live-tag" v-if="selected?.status === 'online'">
<i /><span>海康 · LIVE · Mock</span>
</div>
<div class="stage-body">
<template v-if="selected">
<div class="cam-title">{{ selected.name }}</div>
<div class="cam-sub">{{ selected.location }}</div>
<div class="cam-meta">
海康威视 · {{ typeLabel[selected.deviceType] }}
· 通道 {{ selected.channel }}
<template v-if="selected.ip"> · {{ selected.ip }}</template>
· {{ protocolLabel[config?.protocol || 'sdk'] }}
</div>
<p class="cam-note">
实况待 Windows HCNetSDK 桥接接入;当前为占位画面,设备与海康通信配置已可管理。
</p>
</template>
<template v-else>
<div class="stage webrtc-stage" :class="selected?.status || 'offline'">
<WebRtcPlayer v-if="selected" :key="selected.id" :device-id="selected.id" />
<div v-else class="stage-body">
<div class="cam-title">无可用通道</div>
<p class="cam-note">请先在设备管理中启用海康监控通道。</p>
</template>
</div>
<div v-if="selected" class="cam-meta bar">
{{ selected.name }} · {{ selected.location }}
· CH{{ selected.channel }}
<template v-if="selected.ip"> · {{ selected.ip }}</template>
· WebRTC / WHEP
</div>
</div>
</section>
......@@ -509,6 +498,26 @@ onMounted(loadAll)
flex: 1; min-height: 0;
display: grid; grid-template-columns: 1.6fr 1fr; gap: 12px;
}
.webrtc-stage {
display: flex;
flex-direction: column;
min-height: 360px;
padding: 0;
overflow: hidden;
}
.webrtc-stage :deep(.webrtc-player) {
flex: 1;
min-height: 320px;
border-radius: 0;
}
.cam-meta.bar {
flex: 0 0 auto;
padding: 8px 12px;
font-size: 12px;
color: var(--muted);
border-top: 1px solid rgba(62, 200, 255, 0.15);
background: rgba(0, 0, 0, 0.25);
}
.preview-main, .preview-side {
min-height: 0; display: flex; flex-direction: column;
}
......
---
---
---
name: 设备管理与网关架构
overview: 对照合同软件条款,由单一 Windows 协议网关统一承载 Modbus TCP 与海康 HCNetSDK;Node/Vue 平台负责设备主数据、可编辑点位表、采集落库、计量计费与大屏。后台可导入/编辑现场 CSV 点位(如 8 台冰箱温湿度表)。
overview: 对照合同软件条款,由单一 Windows 协议网关统一承载 Modbus TCP 与海康 HCNetSDK;视频实况定稿为 WebRTC(WHEP);Node/Vue 负责设备主数据、点位表、采集落库、计费与大屏。
todos:
- id: docs-arch
content: 固化合同对照、单一 Windows 网关 API 契约与点位表数据模型文档
status: pending
status: completed
- id: db-devices-points
content: PG:gateways/devices/device_points;支持 CSV 导入与后台 CRUD(字段对齐现场点位表)
status: pending
status: completed
- id: ui-devices-points
content: 后台 /devices:设备清单 + 点位表编辑器(导入/导出 CSV、在线改地址倍率绑定)
status: pending
status: completed
- id: win-gateway
content: VS2022+MSVC C++17 实现单一 Windows 协议网关(Modbus 多主机+HCNetSDK)并与平台 HTTP/WS 对接
status: pending
status: completed
- id: platform-io
content: Node 经网关拉数/下发写指令,并入 realtime 快照与落库;点位变更热推送给网关
status: pending
status: completed
- id: video-webrtc
content: WebRTC/WHEP:前端播放器 + Node 反代 + 网关/ZLM 媒体面
status: in_progress
- id: billing-metering
content: 按合同完善计量计费:分区电量峰平谷、水表独立计费、冷量分摊与多维账单
status: pending
......@@ -31,7 +34,8 @@ isProject: false
## 架构决策(本次修订)
1. **协议网关 = 一个 Windows 程序**:同时负责 **Modbus TCP**(`.102/.103/.104`)与 **海康 HCNetSDK**(门禁/摄像)。Node **不再**直接连 Modbus / 加载 SDK。
2. **点位表可后台编辑**:以现场 CSV 为模板(示例:[`19216810104 房间8个冰箱温度.csv`](file:///Users/james/.cursor/projects/Users-james-Documents-2026-00-code-web/attachments/25718a0d-91e1-47b8-ae30-4a01afa89f48/19216810104___8_____.csv)),支持导入、在线增删改、导出;保存后热推送到 Windows 网关生效。
2. **点位表可后台编辑**:以现场 CSV 为模板,支持导入、在线增删改、导出;保存后热推送到 Windows 网关生效。
3. **视频实况 = WebRTC(WHEP)**:大屏与后台统一 `RTCPeerConnection`;媒体面在 Windows(ZLMediaKit / 网关);Node JWT 反代。JPEG 仅作缩略图/联动降级。见 `docs/video-webrtc.md`。
平台(Node + Vue + PG)只做:设备/点位主数据、计费、业务 API、大屏;现场协议全部经 Windows 网关。
......
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