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:
@@ -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' };
|
||||
@@ -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
@@ -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 token,localStorage 持久) */}
|
||||
{isAuthed() ? <App /> : <TenantLogin />}
|
||||
{/* 多租户:未绑定大屏 → 待绑定页(连接码+二维码);已绑定 → 大屏应用 */}
|
||||
{isDeviceBound() ? <App /> : <BindScreen />}
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
|
||||
+6
-5
@@ -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
@@ -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 || {}) },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user