From daf68895800e5515902cba01eef4021d3448a96d Mon Sep 17 00:00:00 2001 From: Pine Date: Mon, 24 Aug 2026 18:15:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=A4=A7=E5=B1=8F):=20=E5=9B=AD=E5=8C=BA?= =?UTF-8?q?=E7=99=BB=E5=BD=95=20+=20=E5=A4=9A=E7=A7=9F=E6=88=B7=20?= =?UTF-8?q?=E2=80=94=20=E5=8A=A8=E6=80=81=E9=A1=B5=E7=9C=89=E5=90=8D?= =?UTF-8?q?=E7=A7=B0/=E4=B8=A4=E6=AE=B5=E7=AE=80=E4=BB=8B=20+=20=E7=A7=9F?= =?UTF-8?q?=E6=88=B7=20MQTT/=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - utils/tenant.js:账密→长效 tenant token(localStorage 持久),login/logout/tenantFetch(附 tenant_id+token)。 - main.jsx 门禁:未登录显示 TenantLogin(登录页),登录后(App)。一次登录持久保持。 - PageHeader:名称+两段式简介改由 getTenantInfo()(后端 /park/auth/login 返回) 驱动,回退默认。 - mqtt.js:订阅 command// 与 tick/(租户频道);useMqttControl 心跳带 tenant_id。 - parkData.js:snapshot 走 tenantFetch(带 tenant_id+token),按租户取数。 - vite build 通过。 --- src/components/PageHeader.jsx | 11 +++++++-- src/components/TenantLogin.jsx | 38 ++++++++++++++++++++++++++++ src/main.jsx | 5 +++- src/utils/mqtt.js | 11 ++++++--- src/utils/parkData.js | 3 ++- src/utils/tenant.js | 45 ++++++++++++++++++++++++++++++++++ src/utils/useMqttControl.js | 2 ++ 7 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 src/components/TenantLogin.jsx create mode 100644 src/utils/tenant.js diff --git a/src/components/PageHeader.jsx b/src/components/PageHeader.jsx index 47db753..7912df5 100644 --- a/src/components/PageHeader.jsx +++ b/src/components/PageHeader.jsx @@ -7,6 +7,7 @@ import { PAGE_ORDER, usePageIndex } from '../utils/pageNav'; import { Avatar, AvatarImage, AvatarFallback } from '@appica/ui-react/avatar' import { Sfx } from '../utils/sounds'; import { getApiBase as API_BASE } from '../config'; +import { getTenantInfo } from '../utils/tenant'; /* ========================================================= 共享页眉 —— 所有大屏页面统一(由 ScreenLayout 注入) @@ -24,6 +25,12 @@ const STATUS_BY_PATH = { export default function PageHeader() { const navigate = useNavigate(); const location = useLocation(); + // 多租户:页眉名称 + 两段式简介取自园区登录信息(后端可配置),回退默认 + const tInfo = getTenantInfo() || {}; + const parkName = tInfo.name || '昆明市大学生创业园'; + const parkSuffix = 'OPC 智能园区数字运营中心'; + const intro0 = (tInfo.intro && tInfo.intro[0]) || '云南省首家政府主办大学生创业孵化园区'; + const intro1 = (tInfo.intro && tInfo.intro[1]) || '空间+孵化+融资+政策+资源+AI赋能+综合服务'; const [now, setNow] = useState(new Date()); const [aboutOpen, setAboutOpen] = useState(false); const [gestureHit, setGestureHit] = useState(false); // 识别到手势 → 惊喜图标 3s @@ -155,8 +162,8 @@ export default function PageHeader() {
-

昆明市大学生创业园 · OPC 智能园区数字运营中心

-
云南省首家政府主办大学生创业孵化园区 · 空间+孵化+融资+政策+资源+AI赋能+综合服务
+

{parkName} · {parkSuffix}

+
{intro0} · {intro1}
{location.pathname !== '/screen' && ( diff --git a/src/components/TenantLogin.jsx b/src/components/TenantLogin.jsx new file mode 100644 index 0000000..b8ce23b --- /dev/null +++ b/src/components/TenantLogin.jsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; +import { login } from '../utils/tenant'; +import '../styles/global.css'; // 复用基础样式 + +export default function TenantLogin() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async (e) => { + e.preventDefault(); + setBusy(true); setErr(''); + try { + await login(username, password); + window.location.href = '/'; + } catch (e2) { + setErr(e2.message || '登录失败'); + } finally { setBusy(false); } + }; + + return ( +
+
+
园区大屏登录
+
请输入园区账号与密码
+ setUsername(e.target.value)} placeholder="账号" + style={inp} autoFocus /> + setPassword(e.target.value)} placeholder="密码" type="password" style={inp} /> + {err ?
{err}
: null} + +
+
+ ); +} + +const inp = { width: '100%', padding: '10px 12px', marginBottom: 12, borderRadius: 8, border: '1px solid #2c3350', background: '#0f1526', color: '#e6edf3', fontSize: 15, outline: 'none' }; +const btn = { width: '100%', padding: '11px 12px', borderRadius: 8, background: '#2f6bff', color: '#fff', border: 'none', fontSize: 16, cursor: 'pointer' }; diff --git a/src/main.jsx b/src/main.jsx index b6e0841..dd6e6a5 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,6 +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 ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "@appica/ui-react/providers/theme-provider"; import { getApiBase } from "./config"; @@ -80,7 +82,8 @@ bootstrapRuntimeConfig().then(() => { - + {/* 多租户:未登录大屏 → 登录页登录(帐密 → 长效 tenant token,localStorage 持久) */} + {isAuthed() ? : } , diff --git a/src/utils/mqtt.js b/src/utils/mqtt.js index 79b2ff0..070d396 100644 --- a/src/utils/mqtt.js +++ b/src/utils/mqtt.js @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import mqtt from 'mqtt'; import { getMqttUrl, getMqttCredentials, MQTT_TOPIC_COMMAND, MQTT_TOPIC_TICK } from '../config'; +import { getTenantId } from './tenant'; /* ========================================================= MQTT 客户端(mqtt.js)—— 后端控制通道 @@ -46,9 +47,13 @@ export function connectMqtt() { client.on('connect', () => { status = 'connected'; notifyStatus(); - // 客户端只监听自己唯一的频道 opc/display/command/(+ 数据 tick)。 - // 不再订阅全局命令频道 → 后端按客户端路由时,他屏的指令绝收不到。 - client.subscribe([MQTT_TOPIC_TICK, `${MQTT_TOPIC_COMMAND}/${clientId}`], { qos: 1 }); + // 多租户:只监听本园区频道 opc/display/command//(+ 本园区数据 tick)。 + // 不再订阅全局命令频道 → 后端按租户+客户端路由时,他屏/他园区的指令绝收不到。 + const tid = getTenantId(); + client.subscribe([ + tid ? `${MQTT_TOPIC_TICK}/${tid}` : MQTT_TOPIC_TICK, + tid ? `${MQTT_TOPIC_COMMAND}/${tid}/${clientId}` : `${MQTT_TOPIC_COMMAND}/${clientId}`, + ], { qos: 1 }); }); client.on('reconnect', () => { status = 'connecting'; diff --git a/src/utils/parkData.js b/src/utils/parkData.js index 3a5f81a..e36db65 100644 --- a/src/utils/parkData.js +++ b/src/utils/parkData.js @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { getApiBase as API_BASE } from '../config'; +import { tenantFetch } from './tenant'; /* ========================================================= 园区数据引擎(共享)—— 供数据大屏 / 媒体播放页复用 @@ -212,7 +213,7 @@ export function useParkSim() { let cancelled = false; const load = async () => { try { - const res = await fetch(`${API_BASE()}/api/dashboard/snapshot`, { cache: 'no-store' }); + const res = await tenantFetch('/api/dashboard/snapshot', { cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (!cancelled && data && data.token) setSnap(data); diff --git a/src/utils/tenant.js b/src/utils/tenant.js new file mode 100644 index 0000000..b07a517 --- /dev/null +++ b/src/utils/tenant.js @@ -0,0 +1,45 @@ +/* 园区租户认证(大屏登录)—— 账号密码 → 长效 tenant token;一次登录持久(localStorage)。 */ +import { getApiBase } from '../config'; + +const TOKEN_KEY = 'dpm_tenant_token'; +const ID_KEY = 'dpm_tenant_id'; +const INFO_KEY = 'dpm_tenant_info'; + +export function getTenantToken() { return localStorage.getItem(TOKEN_KEY) || ''; } +export function getTenantId() { return localStorage.getItem(ID_KEY) || ''; } +export function getTenantInfo() { + try { return JSON.parse(localStorage.getItem(INFO_KEY) || 'null'); } catch { return null; } +} +export function isAuthed() { return !!getTenantToken() && !!getTenantId(); } + +/** 登录:POST /park/auth/login(经 server-core /park);成功写 localStorage。 */ +export async function login(username, password) { + const res = await fetch(`${getApiBase()}/auth/login`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(typeof username === 'object' ? username : { username, password }), + }); + 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; +} + +export function logout() { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(ID_KEY); + localStorage.removeItem(INFO_KEY); + window.location.href = '/'; +} + +/** 带租户 + token 的 fetch:生成 {path}?tenant_id=… 并附 Authorization。 */ +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, { + ...options, + headers: { ...(tok ? { Authorization: `Bearer ${tok}` } : {}), ...(options.headers || {}) }, + }); +} diff --git a/src/utils/useMqttControl.js b/src/utils/useMqttControl.js index 80849cb..87b83fd 100644 --- a/src/utils/useMqttControl.js +++ b/src/utils/useMqttControl.js @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { onMqttMessage, connectMqtt, publishMqtt, getClientId, useMqttStatus } from './mqtt'; import { MQTT_TOPIC_HEARTBEAT, MQTT_TOPIC_COMMAND, getApiBase as API_BASE } from '../config'; +import { getTenantId } from './tenant'; import { Sfx } from './sounds'; import { PAGE_ORDER } from './pageNav'; import { aiStatus } from './statusBus'; @@ -67,6 +68,7 @@ export function useMqttControl() { if (mqttStatus === 'connected') { publishMqtt(MQTT_TOPIC_HEARTBEAT, { client_id: getClientId(), + tenant_id: getTenantId(), page: location.pathname, ts: Date.now(), });