cangku/frontend/src/layouts/BasicLayout.jsx
youruo 70550b5499 feat: 新增二级页面「拣选库位动销分析」(大货库存监控)
- 后端:pickingBinSalesAnalysis 配置/服务/路由,仓位库存取 rpt.jst_packInventoryInfo
  (bin_group 空值=拣选位、存货=存货位),销量按「SKU 占用同类仓位数平均分摊」,
  恒等式「全部库位销量之和 = 有库位 SKU 销量之和」差异为 0
- 动销等级(近 N 天日销,默认 15):爆款>=20、热卖款 10-20、平销款 3-10、
  滞销款-1 1-3、滞销款-2 (0,1)、销售=0
- 前端:大货库存监控下新增二级菜单 + 页面(等级卡片、仓库/类型/天数/仓位筛选、
  分页表格、xlsx 导出),路由/菜单/页面权限四处注册
- 测试:后端 8 项单测通过;前端 lint/test/build 通过
2026-10-07 16:59:39 +08:00

1391 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,
} 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,
};
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',
},
'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' },
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;