feat(大屏): 园区登录 + 多租户 — 动态页眉名称/两段简介 + 租户 MQTT/数据

- utils/tenant.js:账密→长效 tenant token(localStorage 持久),login/logout/tenantFetch(附 tenant_id+token)。
- main.jsx 门禁:未登录显示 TenantLogin(登录页),登录后(App)。一次登录持久保持。
- PageHeader:名称+两段式简介改由 getTenantInfo()(后端 /park/auth/login 返回) 驱动,回退默认。
- mqtt.js:订阅 command/<tid>/<clientId> 与 tick/<tid>(租户频道);useMqttControl 心跳带 tenant_id。
- parkData.js:snapshot 走 tenantFetch(带 tenant_id+token),按租户取数。
- vite build 通过。
This commit is contained in:
Pine
2026-08-24 18:15:11 +08:00
parent d7c82fa9d4
commit daf6889580
7 changed files with 108 additions and 7 deletions
+9 -2
View File
@@ -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() {
</Avatar>
</span>
<div className="bd-header-titles">
<h1 className="bd-header-title">昆明市大学生创业园 <span className="bd-header-title-accent">· OPC 智能园区数字运营中心</span></h1>
<div className="bd-header-sub">云南省首家政府主办大学生创业孵化园区 · <span className="bd-header-content—highlights">空间+孵化+融资+政策+资源+AI赋能+综合服务</span></div>
<h1 className="bd-header-title">{parkName} <span className="bd-header-title-accent">· {parkSuffix}</span></h1>
<div className="bd-header-sub">{intro0} · <span className="bd-header-content—highlights">{intro1}</span></div>
</div>
{location.pathname !== '/screen' && (
<span className="bd-credit">
+38
View File
@@ -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 (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0b1020', color: '#e6edf3' }}>
<form onSubmit={submit} style={{ width: 320, padding: 32, borderRadius: 12, background: '#141a2e', color: '#e6edf3' }}>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>园区大屏登录</div>
<div style={{ fontSize: 13, color: '#8b93a7', marginBottom: 24 }}>请输入园区账号与密码</div>
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="账号"
style={inp} autoFocus />
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder="密码" type="password" style={inp} />
{err ? <div style={{ color: '#f87171', fontSize: 13, marginBottom: 12 }}>{err}</div> : null}
<button type="submit" disabled={busy || !username} style={btn}>{busy ? '登录中…' : '登 录'}</button>
</form>
</div>
);
}
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' };
+4 -1
View File
@@ -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(() => {
<React.StrictMode>
<ErrorBoundary>
<ThemeProvider>
<App />
{/* 多租户:未登录大屏 → 登录页登录(帐密 → 长效 tenant tokenlocalStorage 持久) */}
{isAuthed() ? <App /> : <TenantLogin />}
</ThemeProvider>
</ErrorBoundary>
</React.StrictMode>,
+8 -3
View File
@@ -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/<clientId>+ 数据 tick)。
// 不再订阅全局命令频道 → 后端按客户端路由时,他屏的指令绝收不到。
client.subscribe([MQTT_TOPIC_TICK, `${MQTT_TOPIC_COMMAND}/${clientId}`], { qos: 1 });
// 多租户:只监听本园区频道 opc/display/command/<tenant_id>/<clientId>+ 本园区数据 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';
+2 -1
View File
@@ -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);
+45
View File
@@ -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 || {}) },
});
}
+2
View File
@@ -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(),
});