From cd936f5e92d466862c8ca1cb43cc415f8cc68766 Mon Sep 17 00:00:00 2001 From: youruo <15018737611@163.com> Date: Wed, 23 Sep 2026 17:41:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=AF=8F=E6=97=A5?= =?UTF-8?q?=E8=BF=9B=E9=94=80=E5=AD=98=EF=BC=88=E5=A4=A7=E8=B4=A7=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/layouts/BasicLayout.jsx | 2651 +++++++++++++------------- 1 file changed, 1326 insertions(+), 1325 deletions(-) diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index 6cb7ddc..eabe42e 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -1,1325 +1,1326 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { - AppstoreOutlined, - CloudOutlined, - DashboardOutlined, - DesktopOutlined, - DownOutlined, - ExperimentOutlined, - FundProjectionScreenOutlined, - LineChartOutlined, - LogoutOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, - MessageOutlined, - MoonOutlined, - RightOutlined, - RobotOutlined, - SettingOutlined, - ShoppingOutlined, - TableOutlined, - TeamOutlined, - UserOutlined, - SunOutlined, -} from '@ant-design/icons'; -import { Dropdown, Switch } from 'antd'; -import { verifyToken } from '../api/auth'; -import { getSetupStatus, getSystemConfig } from '../api/admin'; -import { getInstallStatus } from '../api/install'; -import ErrorBoundary from '../components/ErrorBoundary'; -import { AppShellSkeleton } from '../components/AppLoadingSkeleton'; -import { DEFAULT_SIDEBAR_CONFIG, parseSidebarConfig, sortSidebarEntries } from '../config/sidebarNavigation'; -import { - canRestoreDesktopSessionImmediately, - isDesktopEmbedSession, - markDesktopEmbedSession, - requestDesktopSessionRenewal, -} from '../utils/desktopEmbedSession'; - -const AUTH_USER_STORAGE_KEY = 'auth_user'; -const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; -const BRAND_HOME_URL = 'https://yyd.jiansuntuzhi.cn/analysis/summary'; -const PLANNING_HOME_URL = '/planning/dev-style-preview'; -const FALLBACK_SITE_NAME = '拼多多自动化运营平台'; -const FALLBACK_LOGO = 'https://s3.jiansun.vip/gongxiangpan/logos/1775097467497-1d6a81b8a76a.png'; -const readSidebarDarkModePreference = () => { - try { - const value = localStorage.getItem(SIDEBAR_DARK_MODE_STORAGE_KEY); - if (value === 'dark') return true; - if (value === 'light') return false; - } catch { - // Local persistence is optional in restricted browser contexts. - } - return null; -}; - -const SIDEBAR_ICON_MAP = { - appstore: AppstoreOutlined, - cloud: CloudOutlined, - dashboard: DashboardOutlined, - desktop: DesktopOutlined, - experiment: ExperimentOutlined, - 'line-chart': LineChartOutlined, - message: MessageOutlined, - projection: FundProjectionScreenOutlined, - robot: RobotOutlined, - setting: SettingOutlined, - shopping: ShoppingOutlined, - table: TableOutlined, - team: TeamOutlined, -}; - -const SIDEBAR_ROUTE_MAP = { - 'dashboard-preview': { path: '/planning/dev-style-preview' }, - 'marketing-events': { - path: '/planning/operation/marketing-events', - activePath: '/planning/operation/marketing-events', - matchMode: 'prefix', - }, - 'promotion-module': { path: '/planning/operation/promotion-module' }, - 'product-monitoring': { path: '/planning/operation/product-monitoring' }, - 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, - 'product-analysis': { path: '/planning/operation/product-analysis' }, - 'work-orders': { path: '/planning/operation/work-orders' }, - 'modular-stock-monitor-accessory': { - path: '/planning/operation/modular-stock-monitor', - activePath: '/planning/operation/modular-stock-monitor', - matchMode: 'prefix', - }, - 'mkh-bom-data': { - path: '/planning/operation/mkh-bom-data', - activePath: '/planning/operation/mkh-bom-data', - matchMode: 'prefix', - }, - settings: { path: '/settings' }, - 'online-desktop-users': { path: '/settings/online-desktop-users' }, - 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, -}; - - -const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { - if (entry.requiresAdmin && !canUseSettings) return []; - if (entry.type === 'section') return [{ ...entry }]; - - const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {}; - const routeMatches = (itemRoute = {}, pathname = '') => { - const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : []; - if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { - return false; - } - - const exactPaths = Array.isArray(itemRoute.matchPaths) ? itemRoute.matchPaths : []; - if (exactPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { - return true; - } - - const matchPath = itemRoute.activePath || itemRoute.path; - if (!matchPath) return false; - return itemRoute.matchMode === 'prefix' - ? pathname === matchPath || pathname.startsWith(`${matchPath}/`) - : pathname === matchPath; - }; - const buildItem = (item) => { - if (item.requiresAdmin && !canUseSettings) return null; - const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {}; - return { - ...item, - icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined, - path: itemRoute.path, - external: Boolean(itemRoute.externalUrl), - onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined, - match: (path) => routeMatches(itemRoute, path), - }; - }; - - const normalized = { - ...entry, - icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined, - path: route.path, - external: Boolean(route.externalUrl), - onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined, - match: (path) => routeMatches(route, path), - }; - - if (entry.type === 'group') { - normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean); - } - - return [normalized]; -}); - -function parseJwtPayload(token) { - if (!token) { - return null; - } - - try { - const [, payload] = token.split('.'); - if (!payload) { - return null; - } - - const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '='); - return JSON.parse(window.atob(padded)); - } catch { - return null; - } -} - -function isExpiredJwtPayload(payload) { - const exp = Number(payload?.exp || 0); - return exp > 0 && Math.floor(Date.now() / 1000) >= exp; -} - -function readStoredAuthUser(tokenSub) { - try { - const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY); - if (!raw) { - return null; - } - - const storedUser = JSON.parse(raw); - const storedSub = storedUser?.sub ?? storedUser?.id; - return storedSub && String(storedSub) === String(tokenSub) ? storedUser : null; - } catch { - return null; - } -} - -function clearAuthSession() { - localStorage.removeItem('token'); - localStorage.removeItem(AUTH_USER_STORAGE_KEY); -} - -function looksLikeMojibake(text) { - return /[ÃÂ][\u0080-\u00ff]|[äåæçèé][\u0080-\u00ff]|[\u00c0-\u00ff][\u0080-\u00bf]/.test(text); -} - -function repairMojibake(text) { - if (!text || !looksLikeMojibake(text) || typeof TextDecoder === 'undefined') { - return text; - } - - try { - const bytes = Uint8Array.from([...text].map((char) => char.charCodeAt(0) & 0xff)); - const repaired = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim(); - return repaired || text; - } catch { - return text; - } -} - -function normalizeDisplayName(...candidates) { - const rawName = candidates.find((item) => typeof item === 'string' && item.trim()); - return repairMojibake(rawName?.trim() || '个人中心'); -} - -function buildFallbackUserFromToken(token) { - const payload = parseJwtPayload(token); - if (!payload?.sub || isExpiredJwtPayload(payload)) { - return null; - } - - const storedUser = readStoredAuthUser(payload.sub); - return { - sub: payload.sub, - username: payload.username || '', - name: payload.name || payload.username || '当前用户', - email: payload.email || '', - role: payload.role || storedUser?.role || 'user', - picture: payload.picture || storedUser?.picture || '', - authSource: payload.authSource || storedUser?.authSource || 'local', - departmentId: payload.departmentId ?? storedUser?.departmentId ?? null, - departmentName: payload.departmentName || storedUser?.departmentName || '', - allowedPageKeys: Array.isArray(payload.allowedPageKeys) - ? payload.allowedPageKeys - : Array.isArray(storedUser?.allowedPageKeys) - ? storedUser.allowedPageKeys - : [], - }; -} - -function canFallbackToJwtPayload(error, fallbackUser) { - if (!fallbackUser) { - return false; - } - - if (fallbackUser.authSource === 'local-preview') { - return true; - } - - const status = Number(error?.response?.status || 0); - return !status || status >= 500; -} - -const BasicLayout = () => { - const location = useLocation(); - const navigate = useNavigate(); - const [user, setUser] = useState(null); - const [authReady, setAuthReady] = useState(false); - const [siteConfig, setSiteConfig] = useState({ siteName: FALLBACK_SITE_NAME, logo: FALLBACK_LOGO }); - const [sidebarConfig, setSidebarConfig] = useState(DEFAULT_SIDEBAR_CONFIG); - const [sidebarCollapsed, setSidebarCollapsed] = useState( - () => DEFAULT_SIDEBAR_CONFIG.collapsedByDefault - ); - const [sidebarDarkMode, setSidebarDarkMode] = useState( - () => readSidebarDarkModePreference() ?? DEFAULT_SIDEBAR_CONFIG.darkModeByDefault - ); - const [expandedSidebarKeys, setExpandedSidebarKeys] = useState( - DEFAULT_SIDEBAR_CONFIG.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) - ); - - const pathname = location.pathname; - const isImmersivePlanningRoute = false; - const canUseSettings = user?.role === 'admin'; - const userDisplayName = normalizeDisplayName(user?.name, user?.username, user?.email); - const userAvatar = user?.picture || siteConfig.logo || FALLBACK_LOGO; - - const sidebarSections = useMemo( - () => buildSidebarSections(sidebarConfig, canUseSettings), - [canUseSettings, sidebarConfig] - ); - useEffect(() => { - const loadSiteConfig = async () => { - try { - const config = await getSystemConfig(); - const nextSiteName = config?.site_name || FALLBACK_SITE_NAME; - const nextLogo = config?.logo || FALLBACK_LOGO; - const nextSidebarConfig = parseSidebarConfig(config?.sidebar_navigation); - - setSiteConfig({ siteName: nextSiteName, logo: nextLogo }); - setSidebarConfig(nextSidebarConfig); - setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault)); - setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault)); - setExpandedSidebarKeys( - nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) - ); - document.title = nextSiteName; - - let link = document.querySelector("link[rel*='icon']"); - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - link.href = nextLogo; - } catch { - document.title = FALLBACK_SITE_NAME; - } - }; - - loadSiteConfig(); - }, []); - - useEffect(() => { - const handleSiteConfigUpdated = (event) => { - const nextSiteName = event.detail?.siteName || FALLBACK_SITE_NAME; - const nextLogo = event.detail?.logo; - - setSiteConfig((current) => ({ - siteName: nextSiteName, - logo: nextLogo || current.logo, - })); - document.title = nextSiteName; - - if (nextLogo) { - let link = document.querySelector("link[rel*='icon']"); - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - link.href = nextLogo; - } - }; - - window.addEventListener('site-config-updated', handleSiteConfigUpdated); - return () => window.removeEventListener('site-config-updated', handleSiteConfigUpdated); - }, []); - - useEffect(() => { - const checkAuth = async () => { - markDesktopEmbedSession(); - const token = localStorage.getItem('token'); - const fallbackUser = buildFallbackUserFromToken(token); - - if (canRestoreDesktopSessionImmediately(isDesktopEmbedSession(), fallbackUser)) { - setUser(fallbackUser); - setAuthReady(true); - - try { - const verification = await verifyToken(); - if (!verification?.valid || !verification.user) { - clearAuthSession(); - requestDesktopSessionRenewal('web-session-invalid'); - return; - } - - const resolvedUser = verification.user; - localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); - setUser(resolvedUser); - - if (resolvedUser.role === 'admin') { - const setup = await getSetupStatus().catch((error) => { - console.warn('Setup status unavailable, continue with current session:', error); - return null; - }); - if (setup?.shouldShowWizard) { - navigate('/setup', { replace: true }); - } - } - } catch (error) { - if (!canFallbackToJwtPayload(error, fallbackUser)) { - clearAuthSession(); - requestDesktopSessionRenewal('web-session-invalid'); - } - } - return; - } - - try { - const installStatusPromise = getInstallStatus().catch(() => ({ required: false })); - const verificationPromise = verifyToken() - .then((res) => ({ res, error: null })) - .catch((error) => ({ res: null, error })); - const setupStatusPromise = fallbackUser?.role === 'admin' - ? getSetupStatus().catch((error) => { - console.warn('Setup status unavailable, continue with current session:', error); - return null; - }) - : null; - const [installStatus, verification] = await Promise.all([ - installStatusPromise, - verificationPromise, - ]); - if (installStatus?.required && !fallbackUser) { - clearAuthSession(); - navigate('/install', { replace: true }); - return; - } - - let resolvedUser = null; - const verifyError = verification.error; - if (verification.res?.valid) { - resolvedUser = verification.res.user; - } else if (verifyError) { - console.warn('Token verification failed:', verifyError); - } - - if (!resolvedUser && canFallbackToJwtPayload(verifyError, fallbackUser)) { - resolvedUser = fallbackUser; - } - - if (resolvedUser) { - localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); - if (resolvedUser.role === 'admin') { - try { - const setup = setupStatusPromise - ? await setupStatusPromise - : await getSetupStatus(); - if (setup?.shouldShowWizard) { - navigate('/setup', { replace: true }); - return; - } - } catch (error) { - console.warn('Setup status unavailable, continue with current session:', error); - } - } - - setUser(resolvedUser); - } else if (installStatus?.required) { - clearAuthSession(); - navigate('/install', { replace: true }); - } else { - clearAuthSession(); - if (!requestDesktopSessionRenewal('web-session-missing')) { - navigate('/login'); - } - } - } catch (error) { - console.error(error); - if (fallbackUser) { - setUser(fallbackUser); - } else { - clearAuthSession(); - if (!requestDesktopSessionRenewal('web-session-invalid')) { - navigate('/login'); - } - } - } finally { - setAuthReady(true); - } - }; - - checkAuth(); - }, [navigate]); - - useEffect(() => { - if (!authReady || !user) { - return; - } - - if (pathname === '/' || pathname === '/dashboard') { - navigate(PLANNING_HOME_URL, { replace: true }); - return; - } - - if (pathname.startsWith('/settings') && !canUseSettings) { - navigate(PLANNING_HOME_URL, { replace: true }); - return; - } - - const isAllowedPath = ( - pathname === '/planning' - || pathname.startsWith('/planning/') - || pathname === '/settings' - || pathname.startsWith('/settings/') - ); - - if (!isAllowedPath) { - navigate(PLANNING_HOME_URL, { replace: true }); - } - }, [authReady, canUseSettings, navigate, pathname, user]); - - const handleLogout = () => { - clearAuthSession(); - navigate('/login'); - }; - - const userMenu = [ - canUseSettings - ? { - key: 'settings', - icon: , - label: '系统设置', - onClick: () => navigate('/settings'), - } - : null, - { - key: 'logout', - icon: , - label: '退出登录', - onClick: handleLogout, - }, - ].filter(Boolean); - - const isSidebarEntryActive = (entry) => { - if (!entry) return false; - if (entry.match?.(pathname)) return true; - return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child)); - }; - - const toggleSidebarGroup = (key) => { - setSidebarCollapsed(false); - setExpandedSidebarKeys((keys) => ( - keys.includes(key) - ? keys.filter((item) => item !== key) - : [...keys, key] - )); - }; - - const handleSidebarLeafClick = (item) => { - if (item.external) { - item.onClick?.(); - return; - } - if (item.path) { - navigate(item.path); - } - }; - - const handleSidebarDarkModeChange = (checked) => { - setSidebarDarkMode(checked); - try { - localStorage.setItem(SIDEBAR_DARK_MODE_STORAGE_KEY, checked ? 'dark' : 'light'); - } catch { - // Keep the current-session preference when persistence is unavailable. - } - }; - - if (!authReady) { - return ; - } - - return ( -
- - -
- - - -
- - -
- ); -}; - -export default BasicLayout; +import React, { useEffect, useMemo, useState } from 'react'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { + AppstoreOutlined, + CloudOutlined, + DashboardOutlined, + DesktopOutlined, + DownOutlined, + ExperimentOutlined, + FundProjectionScreenOutlined, + LineChartOutlined, + LogoutOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + MessageOutlined, + MoonOutlined, + RightOutlined, + RobotOutlined, + SettingOutlined, + ShoppingOutlined, + TableOutlined, + TeamOutlined, + UserOutlined, + SunOutlined, +} from '@ant-design/icons'; +import { Dropdown, Switch } from 'antd'; +import { verifyToken } from '../api/auth'; +import { getSetupStatus, getSystemConfig } from '../api/admin'; +import { getInstallStatus } from '../api/install'; +import ErrorBoundary from '../components/ErrorBoundary'; +import { AppShellSkeleton } from '../components/AppLoadingSkeleton'; +import { DEFAULT_SIDEBAR_CONFIG, parseSidebarConfig, sortSidebarEntries } from '../config/sidebarNavigation'; +import { + canRestoreDesktopSessionImmediately, + isDesktopEmbedSession, + markDesktopEmbedSession, + requestDesktopSessionRenewal, +} from '../utils/desktopEmbedSession'; + +const AUTH_USER_STORAGE_KEY = 'auth_user'; +const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; +const BRAND_HOME_URL = 'https://yyd.jiansuntuzhi.cn/analysis/summary'; +const PLANNING_HOME_URL = '/planning/dev-style-preview'; +const FALLBACK_SITE_NAME = '拼多多自动化运营平台'; +const FALLBACK_LOGO = 'https://s3.jiansun.vip/gongxiangpan/logos/1775097467497-1d6a81b8a76a.png'; +const readSidebarDarkModePreference = () => { + try { + const value = localStorage.getItem(SIDEBAR_DARK_MODE_STORAGE_KEY); + if (value === 'dark') return true; + if (value === 'light') return false; + } catch { + // Local persistence is optional in restricted browser contexts. + } + return null; +}; + +const SIDEBAR_ICON_MAP = { + appstore: AppstoreOutlined, + cloud: CloudOutlined, + dashboard: DashboardOutlined, + desktop: DesktopOutlined, + experiment: ExperimentOutlined, + 'line-chart': LineChartOutlined, + message: MessageOutlined, + projection: FundProjectionScreenOutlined, + robot: RobotOutlined, + setting: SettingOutlined, + shopping: ShoppingOutlined, + table: TableOutlined, + team: TeamOutlined, +}; + +const SIDEBAR_ROUTE_MAP = { + 'dashboard-preview': { path: '/planning/dev-style-preview' }, + 'marketing-events': { + path: '/planning/operation/marketing-events', + activePath: '/planning/operation/marketing-events', + matchMode: 'prefix', + }, + 'promotion-module': { path: '/planning/operation/promotion-module' }, + 'product-monitoring': { path: '/planning/operation/product-monitoring' }, + 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, + 'product-analysis': { path: '/planning/operation/product-analysis' }, + 'work-orders': { path: '/planning/operation/work-orders' }, + 'modular-stock-monitor-accessory': { + path: '/planning/operation/modular-stock-monitor', + activePath: '/planning/operation/modular-stock-monitor', + matchMode: 'prefix', + }, + 'mkh-bom-data': { + path: '/planning/operation/mkh-bom-data', + activePath: '/planning/operation/mkh-bom-data', + matchMode: 'prefix', + }, + 'daily-inventory': { path: '/planning/operation/daily-inventory' }, + settings: { path: '/settings' }, + 'online-desktop-users': { path: '/settings/online-desktop-users' }, + 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, +}; + + +const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { + if (entry.requiresAdmin && !canUseSettings) return []; + if (entry.type === 'section') return [{ ...entry }]; + + const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {}; + const routeMatches = (itemRoute = {}, pathname = '') => { + const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : []; + if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return false; + } + + const exactPaths = Array.isArray(itemRoute.matchPaths) ? itemRoute.matchPaths : []; + if (exactPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return true; + } + + const matchPath = itemRoute.activePath || itemRoute.path; + if (!matchPath) return false; + return itemRoute.matchMode === 'prefix' + ? pathname === matchPath || pathname.startsWith(`${matchPath}/`) + : pathname === matchPath; + }; + const buildItem = (item) => { + if (item.requiresAdmin && !canUseSettings) return null; + const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {}; + return { + ...item, + icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined, + path: itemRoute.path, + external: Boolean(itemRoute.externalUrl), + onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined, + match: (path) => routeMatches(itemRoute, path), + }; + }; + + const normalized = { + ...entry, + icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined, + path: route.path, + external: Boolean(route.externalUrl), + onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined, + match: (path) => routeMatches(route, path), + }; + + if (entry.type === 'group') { + normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean); + } + + return [normalized]; +}); + +function parseJwtPayload(token) { + if (!token) { + return null; + } + + try { + const [, payload] = token.split('.'); + if (!payload) { + return null; + } + + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '='); + return JSON.parse(window.atob(padded)); + } catch { + return null; + } +} + +function isExpiredJwtPayload(payload) { + const exp = Number(payload?.exp || 0); + return exp > 0 && Math.floor(Date.now() / 1000) >= exp; +} + +function readStoredAuthUser(tokenSub) { + try { + const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY); + if (!raw) { + return null; + } + + const storedUser = JSON.parse(raw); + const storedSub = storedUser?.sub ?? storedUser?.id; + return storedSub && String(storedSub) === String(tokenSub) ? storedUser : null; + } catch { + return null; + } +} + +function clearAuthSession() { + localStorage.removeItem('token'); + localStorage.removeItem(AUTH_USER_STORAGE_KEY); +} + +function looksLikeMojibake(text) { + return /[ÃÂ][\u0080-\u00ff]|[äåæçèé][\u0080-\u00ff]|[\u00c0-\u00ff][\u0080-\u00bf]/.test(text); +} + +function repairMojibake(text) { + if (!text || !looksLikeMojibake(text) || typeof TextDecoder === 'undefined') { + return text; + } + + try { + const bytes = Uint8Array.from([...text].map((char) => char.charCodeAt(0) & 0xff)); + const repaired = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim(); + return repaired || text; + } catch { + return text; + } +} + +function normalizeDisplayName(...candidates) { + const rawName = candidates.find((item) => typeof item === 'string' && item.trim()); + return repairMojibake(rawName?.trim() || '个人中心'); +} + +function buildFallbackUserFromToken(token) { + const payload = parseJwtPayload(token); + if (!payload?.sub || isExpiredJwtPayload(payload)) { + return null; + } + + const storedUser = readStoredAuthUser(payload.sub); + return { + sub: payload.sub, + username: payload.username || '', + name: payload.name || payload.username || '当前用户', + email: payload.email || '', + role: payload.role || storedUser?.role || 'user', + picture: payload.picture || storedUser?.picture || '', + authSource: payload.authSource || storedUser?.authSource || 'local', + departmentId: payload.departmentId ?? storedUser?.departmentId ?? null, + departmentName: payload.departmentName || storedUser?.departmentName || '', + allowedPageKeys: Array.isArray(payload.allowedPageKeys) + ? payload.allowedPageKeys + : Array.isArray(storedUser?.allowedPageKeys) + ? storedUser.allowedPageKeys + : [], + }; +} + +function canFallbackToJwtPayload(error, fallbackUser) { + if (!fallbackUser) { + return false; + } + + if (fallbackUser.authSource === 'local-preview') { + return true; + } + + const status = Number(error?.response?.status || 0); + return !status || status >= 500; +} + +const BasicLayout = () => { + const location = useLocation(); + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [authReady, setAuthReady] = useState(false); + const [siteConfig, setSiteConfig] = useState({ siteName: FALLBACK_SITE_NAME, logo: FALLBACK_LOGO }); + const [sidebarConfig, setSidebarConfig] = useState(DEFAULT_SIDEBAR_CONFIG); + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => DEFAULT_SIDEBAR_CONFIG.collapsedByDefault + ); + const [sidebarDarkMode, setSidebarDarkMode] = useState( + () => readSidebarDarkModePreference() ?? DEFAULT_SIDEBAR_CONFIG.darkModeByDefault + ); + const [expandedSidebarKeys, setExpandedSidebarKeys] = useState( + DEFAULT_SIDEBAR_CONFIG.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) + ); + + const pathname = location.pathname; + const isImmersivePlanningRoute = false; + const canUseSettings = user?.role === 'admin'; + const userDisplayName = normalizeDisplayName(user?.name, user?.username, user?.email); + const userAvatar = user?.picture || siteConfig.logo || FALLBACK_LOGO; + + const sidebarSections = useMemo( + () => buildSidebarSections(sidebarConfig, canUseSettings), + [canUseSettings, sidebarConfig] + ); + useEffect(() => { + const loadSiteConfig = async () => { + try { + const config = await getSystemConfig(); + const nextSiteName = config?.site_name || FALLBACK_SITE_NAME; + const nextLogo = config?.logo || FALLBACK_LOGO; + const nextSidebarConfig = parseSidebarConfig(config?.sidebar_navigation); + + setSiteConfig({ siteName: nextSiteName, logo: nextLogo }); + setSidebarConfig(nextSidebarConfig); + setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault)); + setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault)); + setExpandedSidebarKeys( + nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) + ); + document.title = nextSiteName; + + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = nextLogo; + } catch { + document.title = FALLBACK_SITE_NAME; + } + }; + + loadSiteConfig(); + }, []); + + useEffect(() => { + const handleSiteConfigUpdated = (event) => { + const nextSiteName = event.detail?.siteName || FALLBACK_SITE_NAME; + const nextLogo = event.detail?.logo; + + setSiteConfig((current) => ({ + siteName: nextSiteName, + logo: nextLogo || current.logo, + })); + document.title = nextSiteName; + + if (nextLogo) { + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = nextLogo; + } + }; + + window.addEventListener('site-config-updated', handleSiteConfigUpdated); + return () => window.removeEventListener('site-config-updated', handleSiteConfigUpdated); + }, []); + + useEffect(() => { + const checkAuth = async () => { + markDesktopEmbedSession(); + const token = localStorage.getItem('token'); + const fallbackUser = buildFallbackUserFromToken(token); + + if (canRestoreDesktopSessionImmediately(isDesktopEmbedSession(), fallbackUser)) { + setUser(fallbackUser); + setAuthReady(true); + + try { + const verification = await verifyToken(); + if (!verification?.valid || !verification.user) { + clearAuthSession(); + requestDesktopSessionRenewal('web-session-invalid'); + return; + } + + const resolvedUser = verification.user; + localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); + setUser(resolvedUser); + + if (resolvedUser.role === 'admin') { + const setup = await getSetupStatus().catch((error) => { + console.warn('Setup status unavailable, continue with current session:', error); + return null; + }); + if (setup?.shouldShowWizard) { + navigate('/setup', { replace: true }); + } + } + } catch (error) { + if (!canFallbackToJwtPayload(error, fallbackUser)) { + clearAuthSession(); + requestDesktopSessionRenewal('web-session-invalid'); + } + } + return; + } + + try { + const installStatusPromise = getInstallStatus().catch(() => ({ required: false })); + const verificationPromise = verifyToken() + .then((res) => ({ res, error: null })) + .catch((error) => ({ res: null, error })); + const setupStatusPromise = fallbackUser?.role === 'admin' + ? getSetupStatus().catch((error) => { + console.warn('Setup status unavailable, continue with current session:', error); + return null; + }) + : null; + const [installStatus, verification] = await Promise.all([ + installStatusPromise, + verificationPromise, + ]); + if (installStatus?.required && !fallbackUser) { + clearAuthSession(); + navigate('/install', { replace: true }); + return; + } + + let resolvedUser = null; + const verifyError = verification.error; + if (verification.res?.valid) { + resolvedUser = verification.res.user; + } else if (verifyError) { + console.warn('Token verification failed:', verifyError); + } + + if (!resolvedUser && canFallbackToJwtPayload(verifyError, fallbackUser)) { + resolvedUser = fallbackUser; + } + + if (resolvedUser) { + localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); + if (resolvedUser.role === 'admin') { + try { + const setup = setupStatusPromise + ? await setupStatusPromise + : await getSetupStatus(); + if (setup?.shouldShowWizard) { + navigate('/setup', { replace: true }); + return; + } + } catch (error) { + console.warn('Setup status unavailable, continue with current session:', error); + } + } + + setUser(resolvedUser); + } else if (installStatus?.required) { + clearAuthSession(); + navigate('/install', { replace: true }); + } else { + clearAuthSession(); + if (!requestDesktopSessionRenewal('web-session-missing')) { + navigate('/login'); + } + } + } catch (error) { + console.error(error); + if (fallbackUser) { + setUser(fallbackUser); + } else { + clearAuthSession(); + if (!requestDesktopSessionRenewal('web-session-invalid')) { + navigate('/login'); + } + } + } finally { + setAuthReady(true); + } + }; + + checkAuth(); + }, [navigate]); + + useEffect(() => { + if (!authReady || !user) { + return; + } + + if (pathname === '/' || pathname === '/dashboard') { + navigate(PLANNING_HOME_URL, { replace: true }); + return; + } + + if (pathname.startsWith('/settings') && !canUseSettings) { + navigate(PLANNING_HOME_URL, { replace: true }); + return; + } + + const isAllowedPath = ( + pathname === '/planning' + || pathname.startsWith('/planning/') + || pathname === '/settings' + || pathname.startsWith('/settings/') + ); + + if (!isAllowedPath) { + navigate(PLANNING_HOME_URL, { replace: true }); + } + }, [authReady, canUseSettings, navigate, pathname, user]); + + const handleLogout = () => { + clearAuthSession(); + navigate('/login'); + }; + + const userMenu = [ + canUseSettings + ? { + key: 'settings', + icon: , + label: '系统设置', + onClick: () => navigate('/settings'), + } + : null, + { + key: 'logout', + icon: , + label: '退出登录', + onClick: handleLogout, + }, + ].filter(Boolean); + + const isSidebarEntryActive = (entry) => { + if (!entry) return false; + if (entry.match?.(pathname)) return true; + return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child)); + }; + + const toggleSidebarGroup = (key) => { + setSidebarCollapsed(false); + setExpandedSidebarKeys((keys) => ( + keys.includes(key) + ? keys.filter((item) => item !== key) + : [...keys, key] + )); + }; + + const handleSidebarLeafClick = (item) => { + if (item.external) { + item.onClick?.(); + return; + } + if (item.path) { + navigate(item.path); + } + }; + + const handleSidebarDarkModeChange = (checked) => { + setSidebarDarkMode(checked); + try { + localStorage.setItem(SIDEBAR_DARK_MODE_STORAGE_KEY, checked ? 'dark' : 'light'); + } catch { + // Keep the current-session preference when persistence is unavailable. + } + }; + + if (!authReady) { + return ; + } + + return ( +
+ + +
+ + + +
+ + +
+ ); +}; + +export default BasicLayout;