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