From 4257db2e262a48604c728fe9e8ff04dfd75af8bf Mon Sep 17 00:00:00 2001 From: Pine Date: Mon, 24 Aug 2026 20:14:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=A4=A7=E5=B1=8F):=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E6=B5=81=20=E2=80=94=20=E6=B0=B8=E4=B9=85?= =?UTF-8?q?=E8=AE=BE=E5=A4=87ID+=E5=BE=85=E7=BB=91=E5=AE=9A=E9=A1=B5(?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E7=A0=81)+MQTT=E7=BB=91=E5=AE=9A=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.getDeviceId:首启生成并持久化永久设备ID,作为 MQTT client_id(稳定可控)。 - tenant.js 改设备绑定:registerDevice/requestBindCode/applyBound(存 token+tenant)/isDeviceBound。 - mqtt.js:client_id=device_id;订阅 bind/(未绑定确认)+command/tick(租户)。 - main.jsx:未绑定→BindScreen(注册+取8位码+监听绑定频道, 绑定后 reload);已绑定→App。 - BindScreen:展示 8 位连接码+设备ID+等待园区端绑定。yarn build 通过。 --- src/components/BindScreen.jsx | 49 ++++++++++++++++++++++++++++++ src/config.js | 12 ++++++++ src/main.jsx | 8 ++--- src/utils/mqtt.js | 11 ++++--- src/utils/tenant.js | 57 ++++++++++++++++++----------------- 5 files changed, 101 insertions(+), 36 deletions(-) create mode 100644 src/components/BindScreen.jsx diff --git a/src/components/BindScreen.jsx b/src/components/BindScreen.jsx new file mode 100644 index 0000000..7a8da68 --- /dev/null +++ b/src/components/BindScreen.jsx @@ -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 ( +
+
+
大屏待绑定
+
请在园区管理端输入以下连接码绑定本屏
+ {err ?
{err}
: null} +
{code || '·······'}
+
设备 ID:{deviceId}
+ {bound &&
绑定成功,正在进入园区…
} +
输入码后,大屏会自动跳转到该园区的大屏内容
+
+
+ ); +} + +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' }; diff --git a/src/config.js b/src/config.js index f630723..9b4d367 100644 --- a/src/config.js +++ b/src/config.js @@ -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; +} diff --git a/src/main.jsx b/src/main.jsx index dd6e6a5..e7232ed 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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(() => { - {/* 多租户:未登录大屏 → 登录页登录(帐密 → 长效 tenant token,localStorage 持久) */} - {isAuthed() ? : } + {/* 多租户:未绑定大屏 → 待绑定页(连接码+二维码);已绑定 → 大屏应用 */} + {isDeviceBound() ? : } , diff --git a/src/utils/mqtt.js b/src/utils/mqtt.js index 070d396..4987518 100644 --- a/src/utils/mqtt.js +++ b/src/utils/mqtt.js @@ -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//(+ 本园区数据 tick)。 - // 不再订阅全局命令频道 → 后端按租户+客户端路由时,他屏/他园区的指令绝收不到。 + // 多租户:只监听本园区频道 command//(+ tick/); + // 未绑定时仍订阅 绑定频道 bind/(设备绑定确认),并接收全局 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 }); diff --git a/src/utils/tenant.js b/src/utils/tenant.js index b07a517..1cacfb0 100644 --- a/src/utils/tenant.js +++ b/src/utils/tenant.js @@ -1,44 +1,47 @@ -/* 园区租户认证(大屏登录)—— 账号密码 → 长效 tenant token;一次登录持久(localStorage)。 */ -import { getApiBase } from '../config'; +/* 大屏租户(设备绑定)—— 永久设备 ID + 连接码绑定 → 设备 token,作为身份。 + 流程:首启注册设备 → 申请 8 位码 → 大屏显示二维码+码 → 园区端输码绑定 → + 服务端 MQTT(opc/display/bind/) 通知并下发 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 || {}) }, });