From 1566477b9721fca43be6b9ec46825660fe0eb7e5 Mon Sep 17 00:00:00 2001 From: Pine Date: Tue, 25 Aug 2026 01:52:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=A4=A7=E5=B1=8F):=20=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E7=9B=B4=E8=BF=9E=20park-vision=20+=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E8=BD=AE=E8=AF=A2/=E4=BA=8C=E7=BB=B4?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/识别服务说明 --- docs/win-deploy.md | 22 ++++++++++++- package-lock.json | 14 +++++++-- package.json | 1 + src/components/BindScreen.jsx | 55 +++++++++++++++++++++++++++------ src/config.js | 14 +++++++++ src/main.jsx | 2 ++ src/utils/tenant.js | 10 ++++-- src/voice/useVisionDetection.js | 20 +++++++----- yarn.lock | 5 +++ 9 files changed, 121 insertions(+), 22 deletions(-) diff --git a/docs/win-deploy.md b/docs/win-deploy.md index 3e943ab..29965de 100644 --- a/docs/win-deploy.md +++ b/docs/win-deploy.md @@ -11,13 +11,15 @@ "mqtt_url": "ws://192.168.1.3:8083/mqtt", "mqtt_username": "dpm", "mqtt_password": "123456", - "voice_url": "ws://192.168.1.3:8765/v1/realtime" + "voice_url": "ws://192.168.1.3:8765/v1/realtime", + "vision_api": "http://192.168.1.9:8091" } ``` - `api_base`:server-core 统一入口园区地址(`http://:8090/park`;生产 `https://opc.pinesound.cn/park`),大屏 API/媒体/OTA 都走它 - `mqtt_url`:EMQX Broker 的 **WebSocket** 地址(端口 8083) - `mqtt_username/password`:Broker 鉴权账号(默认 dpm / 123456,与后端 .env 一致) +- `vision_api`:**可选**。视频识别独立服务(code/park-vision,`http://:8091`)。不填=视频识别禁用(摄像头不开启)。也可经 server-core `/park/api/config` 下发(见「三、视频识别」)。 地址解析优先级:**config.json > 后端 /api/config > 构建默认(192.168.1.9)**。 @@ -76,3 +78,21 @@ Test-NetConnection 192.168.1.3 -Port 8083 - 应用日志打印 `[bootstrap] config.json MQTT -> ...` = config.json 生效 - 打印 `[bootstrap] /api/config MQTT -> ...` = 后端下发 - 两者都没有 = 两个来源都不可达,请检查 IP/防火墙 + +## 三、视频识别(可选,独立 park-vision 服务) + +视频识别能力已从 server-core 剥离为**独立项目 `code/park-vision`**(YOLO 帧识别 + LLM 场景语义), +server-core `/park` 不再承接识别请求(`/park/api/vision/frame|llm` 已移除,仅保留遥测 `/api/vision/event`)。 + +**配置方式(二选一)**,未配置则视频识别整体禁用(摄像头不开启、页面显示「手势识别未启用」): + +1. **config.json(部署机)**:`"vision_api": "http://:8091"` +2. **server-core 全局(admin 端)**:运营端「视频识别服务」页启用+填地址 → 写 `park_settings` → + 大屏启动时从 `GET /park/api/config` 取 `vision_api`(enabled=1 才下发) + +**部署完整识别链路**: +```bash +# 独立识别服务(需 torch/ultralytics + 模型 yolov8n-face.pt / yolov8n-pose.pt 置于 models/) +cd code/park-vision && uv sync && uv run uvicorn app.main:app --host 0.0.0.0 --port 8091 +# 放模型后再启动;缺模型时 /vision/frame 返回「模型未加载」 +``` diff --git a/package-lock.json b/package-lock.json index 65e84af..fd226cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dpm", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dpm", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "@appica/icons-react": "^1.0.0", "@appica/ui-react": "^1.1.0", @@ -17,6 +17,7 @@ "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-opener": "^2", "mqtt": "^5.15.2", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.15.0", @@ -3345,6 +3346,15 @@ "lie": "^3.0.2" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", diff --git a/package.json b/package.json index d846de7..1658224 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-opener": "^2", "mqtt": "^5.15.2", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-router-dom": "^7.15.0", diff --git a/src/components/BindScreen.jsx b/src/components/BindScreen.jsx index 7a8da68..ece3ffd 100644 --- a/src/components/BindScreen.jsx +++ b/src/components/BindScreen.jsx @@ -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 (
大屏待绑定
-
请在园区管理端输入以下连接码绑定本屏
+
请在园区管理端扫描二维码,或输入以下连接码绑定本屏
{err ?
{err}
: null} -
{code || '·······'}
+ {code ? ( +
+
+ +
+
扫描二维码 · 或输入下方连接码
+
+ ) : ( +
取码中…
+ )} +
{code || '······'}
设备 ID:{deviceId}
{bound &&
绑定成功,正在进入园区…
} -
输入码后,大屏会自动跳转到该园区的大屏内容
+
绑定成功后,大屏会自动跳转到该园区的大屏内容
); diff --git a/src/config.js b/src/config.js index eb3fd6c..8325e53 100644 --- a/src/config.js +++ b/src/config.js @@ -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, diff --git a/src/main.jsx b/src/main.jsx index e7232ed..f444e69 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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 ? '(已烘焙,忽略)' : ''}`); } diff --git a/src/utils/tenant.js b/src/utils/tenant.js index 1cacfb0..23b917b 100644 --- a/src/utils/tenant.js +++ b/src/utils/tenant.js @@ -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() { diff --git a/src/voice/useVisionDetection.js b/src/voice/useVisionDetection.js index 63e79eb..99d7f0b 100644 --- a/src/voice/useVisionDetection.js +++ b/src/voice/useVisionDetection.js @@ -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'); diff --git a/yarn.lock b/yarn.lock index 0f41502..0ddaa52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1167,6 +1167,11 @@ promise-worker-transferable@^1.0.4: is-promise "^2.1.0" lie "^3.0.2" +qrcode.react@^4.2.0: + version "4.2.0" + resolved "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz" + integrity sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA== + react-day-picker@^10.0.1: version "10.0.1" resolved "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-10.0.1.tgz"