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 || {}) },
});