feat(大屏): 视频识别直连 park-vision + 绑定轮询/二维码
- config.js 增 getVisionApiBase(),优先绑定下发的本园区 vision_api(经 tenant info) - main.jsx bootstrap 读 /park/api/config 的 vision_api - useVisionDetection 帧 POST 到独立 vision 服务;未配置识别整体禁用 - BindScreen 连接码轮询(无码5s/有码30min) + 渲染二维码(qrcode.react)、绑定后入园区 - 部署文档补 vision_api/识别服务说明
This commit is contained in:
@@ -1,21 +1,45 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { getDeviceId, MQTT_TOPIC_BIND } from '../config';
|
||||
import { connectMqtt, onMqttMessage } from '../utils/mqtt';
|
||||
import { registerDevice, requestBindCode, applyBound } from '../utils/tenant';
|
||||
import '../styles/global.css';
|
||||
|
||||
/* 大屏待绑定页:注册设备 + 申请 8 位连接码 + 监听绑定频道;园区端输码绑定后自动进入。 */
|
||||
/* 轮询节奏:取码失败/无码 → 5 秒重取(尽快拿到首个码);取到码但未绑定 → 30 分钟刷新最新码。
|
||||
(后端每次下发都会覆盖旧码——轮换——故大屏按此节奏保持当前码始终为最新可绑定码。) */
|
||||
const RETRY_NO_CODE_MS = 5_000;
|
||||
const REFRESH_HAS_CODE_MS = 30 * 60_000;
|
||||
|
||||
/* 大屏待绑定页:注册设备 + 申请 8 位连接码 + 轮询取码 + 渲染二维码(供园区端扫码)
|
||||
+ 监听绑定频道;园区端输码/扫码绑定后自动进入。 */
|
||||
export default function BindScreen() {
|
||||
const [code, setCode] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [bound, setBound] = useState(false);
|
||||
const codeRef = useRef(''); // 供轮询回调取最新码,决定下次节奏
|
||||
const deviceId = getDeviceId();
|
||||
|
||||
useEffect(() => {
|
||||
connectMqtt();
|
||||
registerDevice().then(() =>
|
||||
requestBindCode().then((r) => { if (r.ok) setCode(r.code); else setErr(r.error || '取码失败'); }).catch((e) => setErr(e.message))
|
||||
).catch((e) => setErr(e.message));
|
||||
|
||||
// 注册设备(幂等;重复注册安全)
|
||||
registerDevice().catch((e) => setErr(e.message));
|
||||
|
||||
// 轮询自调度:取码 → 失败/无码 5s 再取;有码 30 分钟刷新
|
||||
let timer = null;
|
||||
const refreshCode = () => {
|
||||
requestBindCode()
|
||||
.then((r) => {
|
||||
if (r.ok) { codeRef.current = r.code || ''; setCode(r.code); setErr(''); }
|
||||
else { codeRef.current = ''; setErr(r.error || '取码失败'); }
|
||||
})
|
||||
.catch((e) => setErr(e.message))
|
||||
.finally(() => {
|
||||
const delay = codeRef.current ? REFRESH_HAS_CODE_MS : RETRY_NO_CODE_MS;
|
||||
timer = setTimeout(refreshCode, delay);
|
||||
});
|
||||
};
|
||||
refreshCode();
|
||||
|
||||
const off = onMqttMessage((topic, data) => {
|
||||
if (topic === `${MQTT_TOPIC_BIND}/${deviceId}` && data && data.event === 'bound') {
|
||||
@@ -24,19 +48,32 @@ export default function BindScreen() {
|
||||
setTimeout(() => window.location.reload(), 300);
|
||||
}
|
||||
});
|
||||
return off;
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (off) off();
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
return (
|
||||
<div style={wrap}>
|
||||
<div style={card}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 6 }}>大屏待绑定</div>
|
||||
<div style={sub}>请在园区管理端输入以下连接码绑定本屏</div>
|
||||
<div style={sub}>请在园区管理端扫描二维码,或输入以下连接码绑定本屏</div>
|
||||
{err ? <div style={{ color: '#f87171', margin: '10px 0', fontSize: 13 }}>{err}</div> : null}
|
||||
<div style={codeStyle}>{code || '·······'}</div>
|
||||
{code ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div style={{ padding: 12, background: '#fff', borderRadius: 10 }}>
|
||||
<QRCodeSVG value={code} size={180} level="M" includeMargin={false} bgColor="#ffffff" fgColor="#0b1020" />
|
||||
</div>
|
||||
<div style={sub2}>扫描二维码 · 或输入下方连接码</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: 180, height: 180, margin: '10px auto', borderRadius: 10, background: '#0e1428', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4b5568', fontSize: 13 }}>取码中…</div>
|
||||
)}
|
||||
<div style={codeStyle}>{code || '······'}</div>
|
||||
<div style={sub2}>设备 ID:{deviceId}</div>
|
||||
{bound && <div style={{ color: '#34d399', marginTop: 12 }}>绑定成功,正在进入园区…</div>}
|
||||
<div style={sub2}>输入码后,大屏会自动跳转到该园区的大屏内容</div>
|
||||
<div style={sub2}>绑定成功后,大屏会自动跳转到该园区的大屏内容</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,12 @@ const _apiFromWindow = window.__DPM_API__ && !_isLegacyApi(window.__DPM_API__) ?
|
||||
const _apiFromEnv = env.VITE_API_BASE && !_isLegacyApi(env.VITE_API_BASE) ? env.VITE_API_BASE : null;
|
||||
|
||||
export const API_BASE = _apiFromWindow || _apiFromEnv || `http://${resolveHost()}:8090/park`;
|
||||
// 视频识别:独立 park-vision 服务地址。window.__DPM_VISION__(bootstrap 从 /park/api/config 下发)>
|
||||
// > VITE_VISION_API_BASE(构建期)> env 显式 0→禁用;空=视频识别禁用。
|
||||
const _isLegacyVision = (u) => !!(u && (String(u).includes(':10085') || String(u).includes('10085')));
|
||||
const _visionFromWindow = window.__DPM_VISION__ && !_isLegacyVision(window.__DPM_VISION__) ? window.__DPM_VISION__ : null;
|
||||
const _visionFromEnv = env.VITE_VISION_API_BASE && !_isLegacyVision(env.VITE_VISION_API_BASE) ? env.VITE_VISION_API_BASE : null;
|
||||
export const VISION_API_BASE = (_visionFromWindow || _visionFromEnv || '').replace(/\/$/, '');
|
||||
export const MQTT_URL = window.__DPM_MQTT__ || env.VITE_MQTT_URL || `ws://${resolveHost()}:8083/mqtt`;
|
||||
export const MQTT_USERNAME = window.__DPM_MQTT_USER__ || env.VITE_MQTT_USERNAME || '';
|
||||
export const MQTT_PASSWORD = window.__DPM_MQTT_PASS__ || env.VITE_MQTT_PASSWORD || '';
|
||||
@@ -42,6 +48,14 @@ export function getMqttUrl() {
|
||||
return window.__DPM_MQTT__ || MQTT_URL;
|
||||
}
|
||||
|
||||
/** 视频识别服务地址:优先绑定下发的本园区地址(园区端配置,可局域网),再退回环境/窗口配置;空=禁用 */
|
||||
export function getVisionApiBase() {
|
||||
const fromTenant = (function () {
|
||||
try { return JSON.parse(localStorage.getItem('dpm_tenant_info') || 'null'); } catch { return null; }
|
||||
})();
|
||||
return (fromTenant?.vision_api || window.__DPM_VISION__ || VISION_API_BASE || '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function getMqttCredentials() {
|
||||
return {
|
||||
username: window.__DPM_MQTT_USER__ || MQTT_USERNAME,
|
||||
|
||||
@@ -69,6 +69,8 @@ async function bootstrapRuntimeConfig() {
|
||||
if (!bakedMqtt && cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username;
|
||||
if (!bakedMqtt && cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password;
|
||||
if (!import.meta.env.VITE_VOICE_WS && cfg.voice_url) window.__DPM_VOICE_WS__ = cfg.voice_url;
|
||||
// 视频识别:独立 park-vision 地址(enabled 才下发;空=禁用)
|
||||
if (cfg.vision_api) window.__DPM_VISION__ = cfg.vision_api;
|
||||
// eslint-disable-next-line no-console
|
||||
if (cfg.mqtt_url) console.info(`[bootstrap] /api/config MQTT -> ${cfg.mqtt_url}${bakedMqtt ? '(已烘焙,忽略)' : ''}`);
|
||||
}
|
||||
|
||||
+7
-3
@@ -4,13 +4,15 @@
|
||||
import { getApiBase, getDeviceId } from '../config';
|
||||
|
||||
const TOKEN_KEY = 'dpm_device_token';
|
||||
const TENANT_KEY = 'dpm_tenant_info'; // {tenant_id, name, intro}
|
||||
const TENANT_KEY = 'dpm_tenant_info'; // {tenant_id, name, intro, vision_api}
|
||||
|
||||
export function getDeviceToken() { return localStorage.getItem(TOKEN_KEY) || ''; }
|
||||
export function getTenantId() { return (getTenantInfo() || {}).tenant_id || ''; }
|
||||
export function getTenantInfo() {
|
||||
try { return JSON.parse(localStorage.getItem(TENANT_KEY) || 'null'); } catch { return null; }
|
||||
}
|
||||
/** 本园区视频识别服务地址(独立 park-vision,园区端配置,可局域网);空=禁用 */
|
||||
export function getVisionApi() { return (getTenantInfo() || {}).vision_api || ''; }
|
||||
export function isDeviceBound() { return !!getDeviceToken() && !!getTenantInfo(); }
|
||||
|
||||
export async function registerDevice() {
|
||||
@@ -27,9 +29,11 @@ export async function requestBindCode() {
|
||||
}
|
||||
|
||||
export function applyBound(payload) {
|
||||
// payload: {event:'bound', token, tenant_id, name, intro}
|
||||
// payload: {event:'bound', token, tenant_id, name, intro, vision_api}
|
||||
localStorage.setItem(TOKEN_KEY, payload.token);
|
||||
localStorage.setItem(TENANT_KEY, JSON.stringify({ tenant_id: payload.tenant_id, name: payload.name, intro: payload.intro || [] }));
|
||||
localStorage.setItem(TENANT_KEY, JSON.stringify({
|
||||
tenant_id: payload.tenant_id, name: payload.name, intro: payload.intro || [], vision_api: payload.vision_api || '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function clearBinding() {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* =========================================================
|
||||
摄像头实时识别 Hook —— YOLO 人脸检测(后端推理)
|
||||
链路:前端 getUserMedia 本地预览 → 每 ~700ms canvas 抽帧 → JPEG base64
|
||||
POST {API_BASE}/api/vision/frame → 后端 YOLO(yolov8n-face) 推理
|
||||
摄像头实时识别 Hook —— YOLO 人脸检测(独立 park-vision 服务推理)
|
||||
链路:前端 getUserMedia 本地预览 → 每 ~400ms canvas 抽帧 → JPEG base64
|
||||
POST {VISION_API}/vision/frame → park-vision YOLO(yolov8n-face) 推理
|
||||
→ 返回人脸数/框 → 前端驱动状态机
|
||||
- 状态机:检测到人脸(面向大屏)持续 ≥10s → onTrigger;触发后静默 60s
|
||||
- 帧经局域网传到后端(大屏机同机部署则不出设备);画面预览仍本地
|
||||
- 识别在独立 park-vision 服务;画面预览仍本地
|
||||
- 未配置识别地址(VISION_API 空)→ 视频识别整体禁用,不进摄像头/不循环
|
||||
========================================================= */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getApiBase as API_BASE } from '../config';
|
||||
import { getVisionApiBase as VISION_API } from '../config';
|
||||
|
||||
export const VISION_DWELL_MS = 10000; // 面向停留阈值
|
||||
export const VISION_SILENT_MS = 180000; // 触发(自动问候)后静默 3 分钟,期间不重复触发
|
||||
@@ -104,10 +105,10 @@ export default function useVisionDetection({ onTrigger, onGesture, enabled = tru
|
||||
}
|
||||
}
|
||||
|
||||
// JPEG base64 → 后端 YOLO
|
||||
// JPEG base64 → 独立 park-vision YOLO
|
||||
const b64 = canvasRef.current.toDataURL('image/jpeg', 0.6).split(',')[1];
|
||||
setModelPhase('ok');
|
||||
const res = await fetch(`${API_BASE()}/api/vision/frame`, {
|
||||
const res = await fetch(`${VISION_API()}/vision/frame`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: b64 }),
|
||||
@@ -215,6 +216,11 @@ export default function useVisionDetection({ onTrigger, onGesture, enabled = tru
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
if (cameraOn || !enabled) return;
|
||||
// 未配置视频识别服务 → 禁用(admin 在 server-core 配 vision_api,经 /park/api/config 下发;空=关闭)
|
||||
if (!VISION_API()) {
|
||||
setStatus('off');
|
||||
return;
|
||||
}
|
||||
cancelledRef.current = false;
|
||||
setStatus('loading');
|
||||
setModelPhase('loading');
|
||||
|
||||
Reference in New Issue
Block a user