feat(大屏): 设备绑定流 — 永久设备ID+待绑定页(连接码)+MQTT绑定确认

- config.getDeviceId:首启生成并持久化永久设备ID,作为 MQTT client_id(稳定可控)。
- tenant.js 改设备绑定:registerDevice/requestBindCode/applyBound(存 token+tenant)/isDeviceBound。
- mqtt.js:client_id=device_id;订阅 bind/<dev>(未绑定确认)+command/tick(租户)。
- main.jsx:未绑定→BindScreen(注册+取8位码+监听绑定频道, 绑定后 reload);已绑定→App。
- BindScreen:展示 8 位连接码+设备ID+等待园区端绑定。yarn build 通过。
This commit is contained in:
Pine
2026-08-24 20:14:15 +08:00
parent 225c22ad62
commit 4257db2e26
5 changed files with 101 additions and 36 deletions
+49
View File
@@ -0,0 +1,49 @@
import React, { useEffect, useState } from '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 位连接码 + 监听绑定频道;园区端输码绑定后自动进入。 */
export default function BindScreen() {
const [code, setCode] = useState('');
const [err, setErr] = useState('');
const [bound, setBound] = useState(false);
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));
const off = onMqttMessage((topic, data) => {
if (topic === `${MQTT_TOPIC_BIND}/${deviceId}` && data && data.event === 'bound') {
applyBound(data);
setBound(true);
setTimeout(() => window.location.reload(), 300);
}
});
return off;
}, [deviceId]);
return (
<div style={wrap}>
<div style={card}>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 6 }}>大屏待绑定</div>
<div style={sub}>请在园区管理端输入以下连接码绑定本屏</div>
{err ? <div style={{ color: '#f87171', margin: '10px 0', fontSize: 13 }}>{err}</div> : null}
<div style={codeStyle}>{code || '·······'}</div>
<div style={sub2}>设备 ID{deviceId}</div>
{bound && <div style={{ color: '#34d399', marginTop: 12 }}>绑定成功正在进入园区</div>}
<div style={sub2}>输入码后大屏会自动跳转到该园区的大屏内容</div>
</div>
</div>
);
}
const wrap = { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0b1020', color: '#e6edf3' };
const card = { width: 360, padding: 36, borderRadius: 14, background: '#141a2e', textAlign: 'center' };
const sub = { color: '#8b93a7', fontSize: 14, marginBottom: 20 };
const sub2 = { color: '#8b93a7', fontSize: 12, marginTop: 10 };
const codeStyle = { fontSize: 46, fontWeight: 800, letterSpacing: 8, color: '#2f6bff', margin: '14px 0', fontVariantNumeric: 'tabular-nums' };
+12
View File
@@ -60,3 +60,15 @@ export function getVoiceWsUrl() {
export const MQTT_TOPIC_COMMAND = 'opc/display/command';
export const MQTT_TOPIC_TICK = 'opc/dashboard/tick';
export const MQTT_TOPIC_HEARTBEAT = 'opc/display/heartbeat';
export const MQTT_TOPIC_BIND = 'opc/display/bind';
/* ---- 大屏永久设备 ID:首次生成并持久化,作为 MQTT 客户端名 / 绑定凭据 ---- */
const DEVICE_ID_KEY = 'dpm_device_id';
export function getDeviceId() {
let id = localStorage.getItem(DEVICE_ID_KEY);
if (!id) {
id = 'DP-' + Math.random().toString(36).slice(2, 8).toUpperCase() + Date.now().toString(36).slice(-6).toUpperCase();
localStorage.setItem(DEVICE_ID_KEY, id);
}
return id;
}
+4 -4
View File
@@ -1,8 +1,8 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import TenantLogin from "./components/TenantLogin";
import { isAuthed } from "./utils/tenant";
import BindScreen from "./components/BindScreen.jsx";
import { isDeviceBound } from "./utils/tenant";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "@appica/ui-react/providers/theme-provider";
import { getApiBase } from "./config";
@@ -82,8 +82,8 @@ bootstrapRuntimeConfig().then(() => {
<React.StrictMode>
<ErrorBoundary>
<ThemeProvider>
{/* 多租户:未登录大屏 → 登录页登录(帐密 → 长效 tenant tokenlocalStorage 持久) */}
{isAuthed() ? <App /> : <TenantLogin />}
{/* 多租户:未绑定大屏 → 待绑定页(连接码+二维码);已绑定 → 大屏应用 */}
{isDeviceBound() ? <App /> : <BindScreen />}
</ThemeProvider>
</ErrorBoundary>
</React.StrictMode>,
+6 -5
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import mqtt from 'mqtt';
import { getMqttUrl, getMqttCredentials, MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK } from '../config';
import { getMqttUrl, getMqttCredentials, getDeviceId, MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK, MQTT_TOPIC_BIND } from '../config';
import { getTenantId } from './tenant';
/* =========================================================
@@ -32,8 +32,8 @@ function makeClientId() {
export function connectMqtt() {
if (client) return;
try {
// 独立客户端 ID:避免多屏/多实例/多标签页同 ID 被 broker 互相踢下线
clientId = makeClientId();
// 永久设备 ID 作为 MQTT 客户端名(稳定、可被园区端精准控制)
clientId = getDeviceId();
// 连接时惰性读取 MQTT 地址/账号:打包部署时 main.jsx 引导已从后端 /api/config 覆盖
const { username, password } = getMqttCredentials();
client = mqtt.connect(getMqttUrl(), {
@@ -47,10 +47,11 @@ export function connectMqtt() {
client.on('connect', () => {
status = 'connected';
notifyStatus();
// 多租户:只监听本园区频道 opc/display/command/<tenant_id>/<clientId>+ 本园区数据 tick)。
// 不再订阅全局命令频道 → 后端按租户+客户端路由时,他屏/他园区的指令绝收不到
// 多租户:只监听本园区频道 command/<tenant_id>/<clientId>+ tick/<tenant_id>);
// 未绑定时仍订阅 绑定频道 bind/<clientId>(设备绑定确认),并接收全局 tick 回退
const tid = getTenantId();
client.subscribe([
`${MQTT_TOPIC_BIND}/${clientId}`, // 绑定确认(未绑定/重绑)
tid ? `${MQTT_TOPIC_TICK}/${tid}` : MQTT_TOPIC_TICK,
tid ? `${MQTT_TOPIC_COMMAND}/${tid}/${clientId}` : `${MQTT_TOPIC_COMMAND}/${clientId}`,
], { qos: 1 });
+30 -27
View File
@@ -1,44 +1,47 @@
/* 园区租户认证(大屏登录)—— 账号密码 → 长效 tenant token;一次登录持久(localStorage)。 */
import { getApiBase } from '../config';
/* 大屏租户(设备绑定)—— 永久设备 ID + 连接码绑定 → 设备 token,作为身份。
流程:首启注册设备 → 申请 8 位码 → 大屏显示二维码+码 → 园区端输码绑定 →
服务端 MQTT(opc/display/bind/<dev>) 通知并下发 token → token 存 localStorage 持久。 */
import { getApiBase, getDeviceId } from '../config';
const TOKEN_KEY = 'dpm_tenant_token';
const ID_KEY = 'dpm_tenant_id';
const INFO_KEY = 'dpm_tenant_info';
const TOKEN_KEY = 'dpm_device_token';
const TENANT_KEY = 'dpm_tenant_info'; // {tenant_id, name, intro}
export function getTenantToken() { return localStorage.getItem(TOKEN_KEY) || ''; }
export function getTenantId() { return localStorage.getItem(ID_KEY) || ''; }
export function getDeviceToken() { return localStorage.getItem(TOKEN_KEY) || ''; }
export function getTenantId() { return (getTenantInfo() || {}).tenant_id || ''; }
export function getTenantInfo() {
try { return JSON.parse(localStorage.getItem(INFO_KEY) || 'null'); } catch { return null; }
try { return JSON.parse(localStorage.getItem(TENANT_KEY) || 'null'); } catch { return null; }
}
export function isAuthed() { return !!getTenantToken() && !!getTenantId(); }
export function isDeviceBound() { return !!getDeviceToken() && !!getTenantInfo(); }
/** 登录:POST /park/auth/login(经 server-core /park);成功写 localStorage。 */
export async function login(username, password) {
const res = await fetch(`${getApiBase()}/auth/login`, {
export async function registerDevice() {
const res = await fetch(`${getApiBase()}/api/devices/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(typeof username === 'object' ? username : { username, password }),
body: JSON.stringify({ device_id: getDeviceId() }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || '登录失败');
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(ID_KEY, data.tenant_id);
localStorage.setItem(INFO_KEY, JSON.stringify({ name: data.name, intro: data.intro || [] }));
return data;
return res.json();
}
export function logout() {
export async function requestBindCode() {
const res = await fetch(`${getApiBase()}/api/devices/${getDeviceId()}/code`, { method: 'POST' });
return res.json();
}
export function applyBound(payload) {
// payload: {event:'bound', token, tenant_id, name, intro}
localStorage.setItem(TOKEN_KEY, payload.token);
localStorage.setItem(TENANT_KEY, JSON.stringify({ tenant_id: payload.tenant_id, name: payload.name, intro: payload.intro || [] }));
}
export function clearBinding() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ID_KEY);
localStorage.removeItem(INFO_KEY);
localStorage.removeItem(TENANT_KEY);
window.location.href = '/';
}
/** 带租户 + token 的 fetch生成 {path}?tenant_id=… 并附 Authorization。 */
/** 带设备 token 的 fetch:附 Authorization(设备 token)。 */
export function tenantFetch(path, options = {}) {
const tid = getTenantId();
const tok = getTenantToken();
const url = `${getApiBase()}${path}` + (tid ? (path.includes('?') ? '&' : '?') + `tenant_id=${encodeURIComponent(tid)}` : '');
return fetch(url, {
const tok = getDeviceToken();
return fetch(`${getApiBase()}${path}`, {
...options,
headers: { ...(tok ? { Authorization: `Bearer ${tok}` } : {}), ...(options.headers || {}) },
});