cangku/frontend/src/layouts/BasicLayout.jsx
lunanting 55b755c1d9 feat: 新增「缺货补货监控」看板(拣货位缺货 → 从存货位补货)
数据源:聚水潭「箱及仓位库存」rpt.jst_packInventoryInfo(副本里是视图,
按 SKU+货位聚合单次 35~45s,因此服务端缓存 10 分钟 + 启动预热)。

口径:
- 拣货位:按业务导入的货位清单判定(本地表 bin_replenish_pick_bin,按仓库隔离);
  副本没有货位类型主数据,未导入时回退为 pack_type='Bin' 的记录,页面明确标注。
- 拣货位库存 / 存货位库存 / 现有库存合计;
- 目标库存 = max(10, 近15天日销 × 目标天数(默认7));
- 建议补货量 = min(存货位库存, 目标 − 拣货位库存);
- 状态:拣货位缺货 / 无货可补 / 拣货位偏低 / 未设拣货位 / 正常。
  「未设拣货位」单列,避免把整箱直发商品误报成缺货。

后端:binReplenishService + binReplenishSchema + /api/bin-replenish
(list / export / pick-bins GET-POST-DELETE / ping),建表与预热挂在 localSchema。
前端:/planning/operation/bin-replenish-monitor 页面、侧栏入口、页面权限注册,
含 KPI 卡、状态分段筛选、关键字搜索、目标天数、货位清单导入/清除、服务端导出。
2026-10-22 11:08:20 +08:00

1408 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import {
AppstoreOutlined,
CloudOutlined,
DashboardOutlined,
DesktopOutlined,
DownOutlined,
ExperimentOutlined,
FundProjectionScreenOutlined,
InboxOutlined,
LineChartOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MessageOutlined,
MoonOutlined,
RightOutlined,
RobotOutlined,
SettingOutlined,
ShoppingOutlined,
TableOutlined,
TeamOutlined,
UserOutlined,
SunOutlined,
WarningOutlined,
} 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,
inbox: InboxOutlined,
'line-chart': LineChartOutlined,
message: MessageOutlined,
projection: FundProjectionScreenOutlined,
robot: RobotOutlined,
setting: SettingOutlined,
shopping: ShoppingOutlined,
table: TableOutlined,
team: TeamOutlined,
warning: WarningOutlined,
};
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' },
'returns-inventory-cuiqiao': {
path: '/planning/operation/returns-inventory/cuiqiao',
activePath: '/planning/operation/returns-inventory/cuiqiao',
matchMode: 'prefix',
},
'returns-inventory-subei': {
path: '/planning/operation/returns-inventory/subei',
activePath: '/planning/operation/returns-inventory/subei',
matchMode: 'prefix',
},
'returns-inventory-suining': {
path: '/planning/operation/returns-inventory/suining',
activePath: '/planning/operation/returns-inventory/suining',
matchMode: 'prefix',
},
'returns-inventory-shandong': {
path: '/planning/operation/returns-inventory/shandong',
activePath: '/planning/operation/returns-inventory/shandong',
matchMode: 'prefix',
},
'returns-inventory-henan': {
path: '/planning/operation/returns-inventory/henan',
activePath: '/planning/operation/returns-inventory/henan',
matchMode: 'prefix',
},
'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',
},
'warehouse-ship-timeliness': {
path: '/planning/operation/warehouse-ship-timeliness',
activePath: '/planning/operation/warehouse-ship-timeliness',
matchMode: 'prefix',
},
'warehouse-logistics-exception': {
path: '/planning/operation/warehouse-logistics-exception',
activePath: '/planning/operation/warehouse-logistics-exception',
matchMode: 'prefix',
},
'daily-inventory': { path: '/planning/operation/daily-inventory' },
'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' },
'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' },
'bin-replenish-monitor': {
path: '/planning/operation/bin-replenish-monitor',
activePath: '/planning/operation/bin-replenish-monitor',
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 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) return null;
if (item.requiresAdmin && !canUseSettings) return null;
const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {};
const rawChildren = Array.isArray(item.children) ? item.children : null;
const children = rawChildren
? sortSidebarEntries(rawChildren).map((child) => buildItem(child)).filter(Boolean)
: undefined;
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),
...(children && children.length ? { children } : (rawChildren ? { children: [] } : {})),
};
};
const normalized = buildItem(entry);
if (!normalized) return [];
normalized.type = entry.type;
return [normalized];
});
// 递归收集默认展开的菜单 key(支持多级)
const collectDefaultExpandedKeys = (entries = []) => entries.flatMap((entry) => [
...(entry?.defaultExpanded && entry?.key ? [entry.key] : []),
...collectDefaultExpandedKeys(entry?.children || []),
]);
// 递归判断菜单项(含其子孙)是否命中当前路径
const isNavEntryActive = (entry, pathname) => {
if (!entry) return false;
if (entry.match?.(pathname)) return true;
return Array.isArray(entry.children) && entry.children.some((child) => isNavEntryActive(child, pathname));
};
// 侧边栏菜单节点(递归渲染,支持三级及以上)
const SidebarNavNode = ({
item, level, pathname, expandedKeys, onToggle, onLeaf, collapsed,
}) => {
const Icon = item.icon || AppstoreOutlined;
const expandable = Array.isArray(item.children) && item.children.length > 0;
const descendantActive = expandable && item.children.some((child) => isNavEntryActive(child, pathname));
const selfActive = Boolean(item.match?.(pathname));
const active = selfActive || (collapsed && descendantActive);
const expanded = expandable && expandedKeys.includes(item.key) && !collapsed;
const isTop = level <= 1;
const baseClass = isTop ? 'planning-subsystem-nav-item' : 'planning-subsystem-subnav-item';
const iconClass = isTop ? 'planning-subsystem-nav-icon' : 'planning-subsystem-subnav-icon';
return (
<div className="planning-subsystem-nav-group">
<button
type="button"
className={`${baseClass}${active ? ' is-active' : ''}${descendantActive ? ' is-descendant-active' : ''}${expandable ? ' is-expandable' : ''}`}
style={isTop ? undefined : { paddingLeft: 36 + (level - 2) * 20 }}
onClick={() => {
if (expandable) {
onToggle(item.key);
return;
}
onLeaf(item);
}}
title={item.label}
aria-expanded={expandable ? expanded : undefined}
>
<Icon className={iconClass} />
<span className={isTop ? 'planning-subsystem-nav-label' : 'planning-subsystem-subnav-label'}>{item.label}</span>
{expandable ? (
<span className="planning-subsystem-nav-chevron">
{expanded ? <DownOutlined /> : <RightOutlined />}
</span>
) : null}
</button>
{expandable && expanded ? (
<div className="planning-subsystem-subnav">
{item.children.map((child) => (
<SidebarNavNode
key={child.key}
item={child}
level={level + 1}
pathname={pathname}
expandedKeys={expandedKeys}
onToggle={onToggle}
onLeaf={onLeaf}
collapsed={collapsed}
/>
))}
</div>
) : null}
</div>
);
};
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(collectDefaultExpandedKeys(nextSidebarConfig.entries));
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: <SettingOutlined />,
label: '系统设置',
onClick: () => navigate('/settings'),
}
: null,
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
].filter(Boolean);
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 <AppShellSkeleton />;
}
return (
<div className={`planning-subsystem-shell${isImmersivePlanningRoute ? ' is-immersive' : ''}${sidebarCollapsed ? ' is-sidebar-collapsed' : ''}${sidebarDarkMode ? ' is-sidebar-dark' : ''}`}>
<aside className="planning-subsystem-sidebar">
<button
type="button"
className="planning-subsystem-brand"
onClick={() => { window.location.href = BRAND_HOME_URL; }}
title="返回主系统"
>
<span className="planning-subsystem-brand-logo">
{siteConfig.logo ? <img src={siteConfig.logo} alt="" /> : <CloudOutlined />}
</span>
<span className="planning-subsystem-brand-title">{siteConfig.siteName || FALLBACK_SITE_NAME}</span>
</button>
<nav className="planning-subsystem-nav" aria-label="主导航">
{sidebarSections.map((item) => {
if (item.type === 'section') {
return (
<div key={item.key} className="planning-subsystem-nav-section">
{item.label}
</div>
);
}
return (
<SidebarNavNode
key={item.key}
item={item}
level={1}
pathname={pathname}
expandedKeys={expandedSidebarKeys}
onToggle={toggleSidebarGroup}
onLeaf={handleSidebarLeafClick}
collapsed={sidebarCollapsed}
/>
);
})}
</nav>
<div className="planning-subsystem-sidebar-bottom">
<div className="planning-subsystem-sidebar-separator" />
<div className="planning-subsystem-theme-row" title={sidebarDarkMode ? '关闭侧栏暗黑模式' : '开启侧栏暗黑模式'}>
{sidebarDarkMode ? <MoonOutlined /> : <SunOutlined />}
<span className="planning-subsystem-theme-label">暗黑模式</span>
<Switch
size="small"
checked={sidebarDarkMode}
onChange={handleSidebarDarkModeChange}
aria-label="侧栏暗黑模式"
/>
</div>
<div className="planning-subsystem-account-row">
<button
type="button"
className="planning-subsystem-collapse"
onClick={() => setSidebarCollapsed((value) => !value)}
title={sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
<span className="planning-subsystem-collapse-label">{sidebarCollapsed ? '展开' : '收起'}</span>
</button>
<Dropdown menu={{ items: userMenu }} trigger={['click']} placement="topRight">
<button type="button" className="planning-subsystem-user" title={userDisplayName}>
<img src={userAvatar} alt={userDisplayName} />
<span className="planning-subsystem-user-copy">
<strong>{userDisplayName}</strong>
</span>
<span className="planning-subsystem-user-more" aria-hidden="true">
<span />
<span />
<span />
</span>
</button>
</Dropdown>
</div>
</div>
</aside>
<main className={`planning-subsystem-content${isImmersivePlanningRoute ? ' is-immersive' : ''}`}>
<ErrorBoundary key={location.pathname}>
<Outlet context={{ sidebarCollapsed, setSidebarCollapsed }} />
</ErrorBoundary>
</main>
<style>{`
.planning-subsystem-shell {
--sidebar-width: var(--sycm-sidebar-width);
--sidebar-collapsed-width: var(--sycm-sidebar-collapsed-width);
--sidebar-bg: var(--sycm-sidebar-bg);
--sidebar-border: var(--sycm-sidebar-border);
--sidebar-active-bg: var(--sycm-sidebar-active-bg);
--sidebar-active-text: var(--sycm-sidebar-active-text);
--sidebar-hover-bg: var(--sycm-sidebar-hover-bg);
--sidebar-icon: var(--sycm-sidebar-icon-active);
--sidebar-icon-muted: var(--sycm-sidebar-icon);
--sidebar-text: var(--sycm-sidebar-text);
--sidebar-muted: var(--sycm-sidebar-muted);
--sidebar-section: var(--sycm-sidebar-tertiary);
--sidebar-accent: var(--sycm-sidebar-accent);
display: flex;
min-height: 100vh;
background: var(--sycm-bg-page-surface);
font-family: var(--sycm-font-family);
}
.planning-subsystem-shell.is-immersive {
background: var(--sycm-bg-page-surface);
}
.planning-subsystem-shell.is-sidebar-dark {
--sidebar-bg: var(--sycm-sidebar-dark-bg);
--sidebar-border: var(--sycm-sidebar-dark-border);
--sidebar-active-bg: var(--sycm-sidebar-dark-active-bg);
--sidebar-active-text: var(--sycm-sidebar-dark-active-text);
--sidebar-hover-bg: var(--sycm-sidebar-dark-hover-bg);
--sidebar-icon: var(--sycm-sidebar-dark-icon-active);
--sidebar-icon-muted: var(--sycm-sidebar-dark-icon);
--sidebar-text: var(--sycm-sidebar-dark-text);
--sidebar-muted: var(--sycm-sidebar-dark-muted);
--sidebar-section: var(--sycm-sidebar-dark-tertiary);
--sidebar-accent: var(--sycm-sidebar-dark-accent);
}
.planning-subsystem-sidebar {
position: sticky;
top: 0;
display: flex;
width: var(--sidebar-width);
height: 100vh;
flex: 0 0 var(--sidebar-width);
flex-direction: column;
overflow: hidden;
padding: var(--sycm-space-md) var(--sycm-space-xs) var(--sycm-space-sm);
border-right: 1px solid var(--sidebar-border);
background: var(--sidebar-bg);
box-shadow: var(--sycm-shadow-card);
transition:
width 0.25s ease,
flex-basis 0.25s ease,
padding 0.25s ease;
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-sidebar {
width: var(--sidebar-collapsed-width);
flex-basis: var(--sidebar-collapsed-width);
align-items: center;
padding: var(--sycm-space-md) var(--sycm-space-xs) var(--sycm-space-sm);
}
.planning-subsystem-brand {
display: inline-flex;
align-items: center;
width: 100%;
min-height: 52px;
justify-content: flex-start;
gap: 12px;
margin-bottom: var(--sycm-space-sm);
padding: 0 10px;
border: 0;
border-radius: var(--sycm-radius-sm);
background: transparent;
color: var(--sidebar-text);
font-family: inherit;
font-size: var(--sycm-font-size-title);
font-weight: var(--sycm-font-weight-semibold);
line-height: var(--sycm-line-height-tight);
letter-spacing: 0;
cursor: pointer;
}
.planning-subsystem-brand-logo {
display: inline-flex;
width: 32px;
height: 32px;
flex: 0 0 32px;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: var(--sycm-radius-sm);
color: var(--sidebar-accent);
font-size: 24px;
}
.planning-subsystem-brand-logo img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.planning-subsystem-shell.is-sidebar-dark .planning-subsystem-brand-logo {
padding: 4px;
background: rgba(255, 255, 255, 0.94);
}
.planning-subsystem-brand-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.planning-subsystem-nav {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 4px;
overflow: auto;
padding: 0;
}
.planning-subsystem-nav::-webkit-scrollbar {
display: none;
}
.planning-subsystem-nav-group {
display: flex;
width: 100%;
flex-direction: column;
gap: 2px;
}
.planning-subsystem-nav-section {
height: 18px;
margin: 10px 8px 2px;
color: var(--sidebar-section);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
line-height: 18px;
letter-spacing: 0;
white-space: nowrap;
}
.planning-subsystem-nav-item {
position: relative;
display: flex;
width: 100%;
height: 36px;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 0 10px;
border: 0;
border-radius: var(--sycm-radius-sm);
background: transparent;
color: var(--sidebar-text);
font-family: inherit;
font-size: var(--sycm-font-size-body);
font-weight: var(--sycm-font-weight-medium);
line-height: var(--sycm-line-height-tight);
text-align: left;
cursor: pointer;
transition: color 0.18s ease;
}
.planning-subsystem-nav-item::before,
.planning-subsystem-subnav-item::before {
content: '';
position: absolute;
z-index: 0;
inset: 0 24px 0 -8px;
border-radius: 0 var(--sycm-radius-pill) var(--sycm-radius-pill) 0;
background: transparent;
pointer-events: none;
transition: background-color 0.18s ease;
}
.planning-subsystem-nav-item > *,
.planning-subsystem-subnav-item > * {
position: relative;
z-index: 1;
}
.planning-subsystem-nav-item:focus,
.planning-subsystem-subnav-item:focus {
outline: none;
}
.planning-subsystem-nav-item:focus-visible::before,
.planning-subsystem-subnav-item:focus-visible::before {
box-shadow: inset 0 0 0 var(--sycm-line-width-base) var(--sycm-color-primary-border);
}
.planning-subsystem-nav-item:hover {
color: var(--sidebar-text);
}
.planning-subsystem-nav-item:hover::before {
background: var(--sidebar-hover-bg);
}
.planning-subsystem-nav-item.is-active {
color: var(--sidebar-active-text);
font-weight: var(--sycm-font-weight-semibold);
box-shadow: none;
}
.planning-subsystem-nav-item.is-active::before {
background: var(--sidebar-active-bg);
}
.planning-subsystem-nav-item.is-descendant-active:not(.is-active) {
color: var(--sidebar-active-text);
}
.planning-subsystem-nav-icon {
flex: 0 0 17px;
color: var(--sidebar-icon-muted);
font-size: 15px;
}
.planning-subsystem-nav-item.is-active .planning-subsystem-nav-icon,
.planning-subsystem-nav-item.is-descendant-active .planning-subsystem-nav-icon,
.planning-subsystem-nav-item:hover .planning-subsystem-nav-icon {
color: var(--sidebar-icon);
}
.planning-subsystem-nav-label {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.planning-subsystem-nav-chevron {
display: inline-flex;
width: 12px;
height: 12px;
flex: 0 0 12px;
align-items: center;
justify-content: center;
margin-left: auto;
color: var(--sidebar-section);
font-size: var(--sycm-font-size-micro);
line-height: 1;
}
.planning-subsystem-subnav {
display: flex;
width: 100%;
flex-direction: column;
gap: 2px;
margin: 0 0 4px;
}
.planning-subsystem-subnav-item {
position: relative;
display: flex;
width: 100%;
height: var(--sycm-control-height-sm);
align-items: center;
justify-content: flex-start;
gap: 9px;
padding: 0 8px 0 36px;
border: 0;
border-radius: var(--sycm-radius-sm);
background: transparent;
color: var(--sidebar-muted);
font-family: inherit;
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
line-height: 18px;
text-align: left;
cursor: pointer;
transition: color 0.18s ease;
}
.planning-subsystem-subnav-item:hover {
color: var(--sidebar-text);
}
.planning-subsystem-subnav-item:hover::before {
background: var(--sidebar-hover-bg);
}
.planning-subsystem-subnav-item.is-active {
color: var(--sidebar-active-text);
font-weight: var(--sycm-font-weight-semibold);
}
.planning-subsystem-subnav-item.is-active::before {
background: var(--sidebar-active-bg);
}
.planning-subsystem-subnav-item.is-descendant-active:not(.is-active) {
color: var(--sidebar-active-text);
}
.planning-subsystem-subnav-label {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.planning-subsystem-subnav-item.is-expandable .planning-subsystem-nav-chevron {
margin-left: 0;
}
.planning-subsystem-subnav-icon {
flex: 0 0 14px;
color: var(--sidebar-icon-muted);
font-size: var(--sycm-font-size-caption);
}
.planning-subsystem-subnav-item:hover .planning-subsystem-subnav-icon,
.planning-subsystem-subnav-item.is-active .planning-subsystem-subnav-icon {
color: var(--sidebar-icon);
}
.planning-subsystem-nav-badge {
display: inline-flex;
min-width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border-radius: var(--sycm-radius-pill);
padding: 0 5px;
background: var(--sycm-bg-subtle);
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
box-shadow: none;
}
.planning-subsystem-sidebar-bottom {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.planning-subsystem-sidebar-separator {
height: 1px;
margin: 6px 0;
background: var(--sidebar-border);
}
.planning-subsystem-theme-row {
display: flex;
width: 100%;
height: var(--sycm-control-height);
align-items: center;
gap: var(--sycm-space-xs);
padding: 0 8px;
border-radius: var(--sycm-radius-sm);
color: var(--sidebar-muted);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
}
.planning-subsystem-theme-row:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text);
}
.planning-subsystem-theme-row > .anticon {
flex: 0 0 16px;
color: var(--sidebar-icon-muted);
font-size: 14px;
}
.planning-subsystem-theme-label {
min-width: 0;
flex: 1;
white-space: nowrap;
}
.planning-subsystem-theme-row .ant-switch {
flex: 0 0 auto;
background: var(--sidebar-border);
}
.planning-subsystem-theme-row .ant-switch.ant-switch-checked {
background: var(--sidebar-accent);
}
.planning-subsystem-account-row {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 4px;
}
.planning-subsystem-collapse {
display: flex;
width: auto;
height: var(--sycm-control-height-sm);
flex: 0 0 auto;
align-items: center;
justify-content: flex-start;
gap: var(--sycm-space-xs);
padding: 0 8px;
border: 0;
border-radius: var(--sycm-radius-sm);
background: transparent;
color: var(--sidebar-muted);
font-family: inherit;
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
cursor: pointer;
}
.planning-subsystem-collapse:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text);
}
.planning-subsystem-collapse .anticon {
flex: 0 0 16px;
font-size: 14px;
}
.planning-subsystem-user {
display: flex;
width: 100%;
min-width: 0;
min-height: var(--sycm-control-height-sm);
align-items: center;
gap: 8px;
margin-top: 0;
padding: 0 8px;
border: 0;
border-radius: var(--sycm-radius-sm);
background: transparent;
color: var(--sidebar-text);
font-family: inherit;
text-align: left;
cursor: pointer;
}
.planning-subsystem-user:hover {
background: var(--sidebar-hover-bg);
}
.planning-subsystem-user img {
width: 20px;
height: 20px;
flex: 0 0 20px;
border-radius: var(--sycm-radius-pill);
object-fit: cover;
background: var(--sycm-bg-subtle);
}
.planning-subsystem-user-copy {
display: block;
min-width: 0;
flex: 1;
}
.planning-subsystem-user-copy strong {
overflow: hidden;
color: var(--sidebar-text);
font-size: var(--sycm-font-size-caption);
font-weight: var(--sycm-font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.planning-subsystem-user-more {
display: inline-flex;
width: 4px;
flex: 0 0 4px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 3px;
color: var(--sidebar-muted);
}
.planning-subsystem-user-more span {
width: 3px;
height: 3px;
border-radius: var(--sycm-radius-pill);
background: currentColor;
}
.planning-subsystem-content {
min-width: 0;
height: 100vh;
flex: 1;
overflow: auto;
padding: 0;
background: var(--sycm-bg-page-surface);
}
.planning-subsystem-content.is-immersive {
height: 100vh;
padding: 0;
}
.planning-subsystem-content::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.planning-subsystem-content::-webkit-scrollbar-track {
background: transparent;
}
.planning-subsystem-content::-webkit-scrollbar-thumb {
background: var(--sycm-border-strong);
border-radius: var(--sycm-radius-pill);
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-brand,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-nav-item,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-theme-row,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-collapse,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-user {
justify-content: center;
padding-right: 0;
padding-left: 0;
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-brand-title,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-nav-label,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-nav-badge,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-nav-chevron,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-nav-section,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-subnav,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-theme-label,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-collapse-label,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-user-copy,
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-user-more {
display: none;
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-nav-item {
width: 36px;
margin-left: 0;
border-radius: var(--sycm-radius-pill);
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-nav-item::before {
inset: 0;
border-radius: var(--sycm-radius-pill);
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-theme-row {
width: 36px;
min-width: 36px;
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-account-row {
display: flex;
width: 36px;
flex-direction: column;
gap: 6px;
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-theme-row > .anticon {
display: none;
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-collapse {
width: 36px;
min-width: 36px;
color: var(--sidebar-icon-muted);
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-collapse:hover {
color: var(--sidebar-icon);
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-collapse .anticon {
display: inline-flex;
flex: 0 0 16px;
align-items: center;
justify-content: center;
color: currentColor;
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-sidebar-separator {
width: var(--sycm-control-height-sm);
}
.planning-subsystem-shell.is-sidebar-collapsed .planning-subsystem-user img {
width: 20px;
height: 20px;
}
@media (max-width: 768px) {
.planning-subsystem-shell {
--sidebar-width: var(--sycm-sidebar-collapsed-width);
}
.planning-subsystem-sidebar {
width: var(--sidebar-width);
flex-basis: var(--sidebar-width);
align-items: center;
padding: var(--sycm-space-md) var(--sycm-space-xs) var(--sycm-space-sm);
}
.planning-subsystem-content {
height: 100vh;
}
.planning-subsystem-content.is-immersive {
height: 100vh;
}
.planning-subsystem-nav-item,
.planning-subsystem-nav-item:hover,
.planning-subsystem-nav-item.is-active {
width: 36px;
margin-left: 0;
padding-right: 0;
padding-left: 0;
border-radius: var(--sycm-radius-pill);
justify-content: center;
}
.planning-subsystem-nav-item::before {
inset: 0;
border-radius: var(--sycm-radius-pill);
}
.planning-subsystem-brand-title,
.planning-subsystem-nav-label,
.planning-subsystem-nav-badge,
.planning-subsystem-nav-chevron,
.planning-subsystem-nav-section,
.planning-subsystem-subnav,
.planning-subsystem-theme-label,
.planning-subsystem-collapse-label,
.planning-subsystem-user-copy,
.planning-subsystem-user-more {
display: none;
}
}
`}</style>
</div>
);
};
export default BasicLayout;