cangku/frontend/src/layouts/BasicLayout.jsx
yanhui 445347e664 feat: 启用运营分析模块,新增模块化库存监控与 MKH-BOM 资料
本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。

主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
   - retiredFeatures 移出对应退役名单与清表语句,改为正常保留
   - 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
   - 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
   - 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
     当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
   - MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
   name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
   相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
   parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
   而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
   真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)

验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
  Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
  与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
  会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
  --ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致

未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
2026-09-15 16:53:12 +08:00

1325 lines
43 KiB
JavaScript

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: <SettingOutlined />,
label: '系统设置',
onClick: () => navigate('/settings'),
}
: null,
{
key: 'logout',
icon: <LogoutOutlined />,
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 <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>
);
}
const Icon = item.icon;
const expandable = Array.isArray(item.children) && item.children.length > 0;
const descendantActive = expandable && item.children.some((child) => isSidebarEntryActive(child));
const selfActive = Boolean(item.match?.(pathname));
const active = selfActive || (sidebarCollapsed && descendantActive);
const expanded = expandable && expandedSidebarKeys.includes(item.key) && !sidebarCollapsed;
return (
<div key={item.key} className={`planning-subsystem-nav-group${expanded ? ' is-expanded' : ''}`}>
<button
type="button"
className={`planning-subsystem-nav-item${active ? ' is-active' : ''}${descendantActive ? ' is-descendant-active' : ''}${expandable ? ' is-expandable' : ''}`}
onClick={() => {
if (expandable) {
toggleSidebarGroup(item.key);
return;
}
handleSidebarLeafClick(item);
}}
title={item.label}
aria-expanded={expandable ? expanded : undefined}
>
<Icon className="planning-subsystem-nav-icon" />
<span className="planning-subsystem-nav-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) => {
const ChildIcon = child.icon;
const childActive = isSidebarEntryActive(child);
return (
<button
key={child.key}
type="button"
className={`planning-subsystem-subnav-item${childActive ? ' is-active' : ''}`}
onClick={() => handleSidebarLeafClick(child)}
title={child.label}
>
<ChildIcon className="planning-subsystem-subnav-icon" />
<span>{child.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
})}
</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-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;