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:
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 token,localStorage 持久) */}
|
||||
{isAuthed() ? <App /> : <TenantLogin />}
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
|
||||
+8
-3
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user