feat(aihr): 重构运营后台与租户知识治理

- 重构运营总览、侧栏和内容运营工作台\n- 增加大喇叭、成果审核与运营统计链路\n- 补齐租户、知识空间、分类和知识维护闭环\n- 加固标签页租户上下文、停用租户写保护与迁移预检
This commit is contained in:
2026-07-23 01:24:22 +08:00
parent 333c365c6f
commit 163b08ecfe
94 changed files with 8610 additions and 1114 deletions
+116 -28
View File
@@ -1,6 +1,12 @@
<template>
<div class="navbar" :class="'nav' + navType">
<hamburger id="hamburger-container" :is-active="appStore.sidebar.opened" class="hamburger-container" @toggle-click="toggleSideBar" />
<hamburger
v-if="navType !== NavTypeEnum.TOP"
id="hamburger-container"
:is-active="appStore.sidebar.opened"
class="hamburger-container"
@toggle-click="toggleSideBar"
/>
<breadcrumb v-if="navType == NavTypeEnum.LEFT" id="breadcrumb-container" class="breadcrumb-container" />
<top-nav v-if="navType == NavTypeEnum.MIX" id="topmenu-container" class="topmenu-container" />
@@ -11,12 +17,15 @@
</template>
<div class="right-menu flex align-center">
<template v-if="appStore.device !== 'mobile'">
<el-tag v-if="isSuperAdmin && tenantEnabled && !tenantContextVerified" class="mr-2" type="warning" effect="plain"> 租户上下文未确认 </el-tag>
<el-select
v-if="userId === 1 && tenantEnabled"
v-model="companyName"
v-if="isSuperAdmin && tenantEnabled"
v-model="selectedTenantId"
class="min-w-244px mr-2"
clearable
filterable
:disabled="tenantContextSwitching"
:loading="tenantContextSwitching"
reserve-keyword
:placeholder="proxy.$t('navbar.selectTenant')"
@change="dynamicTenantEvent"
@@ -34,7 +43,7 @@
</el-tooltip>
<!-- 消息 -->
<el-tooltip :content="proxy.$t('navbar.message')" effect="dark" placement="bottom">
<div style="display:flex;align-items:center">
<div style="display: flex; align-items: center">
<el-popover placement="bottom" trigger="click" transition="el-zoom-in-top" :width="300" :persistent="false">
<template #reference>
<el-badge :value="newNotice > 0 ? newNotice : ''" :max="99">
@@ -99,14 +108,28 @@ import { useUserStore } from '@/store/modules/user';
import { useSettingsStore } from '@/store/modules/settings';
import { useNoticeStore } from '@/store/modules/notice';
import { getTenantList } from '@/api/login';
import { dynamicClear, dynamicTenant } from '@/api/system/tenant';
import { getDynamicTenant } from '@/api/system/tenant';
import { TenantVO } from '@/api/types';
import notice from './notice/index.vue';
import router from '@/router';
import {
clearDynamicTenantContext,
clearDynamicTenantContextState,
dynamicTenantContextVersion,
dynamicTenantId,
hasDynamicTenant,
isTenantContextVersionCurrent,
markTenantContextUnverified,
restoreDynamicTenantContext,
switchDynamicTenantContext,
tenantContextVerified,
tenantContextSwitching,
waitForTenantRequestContextOwnership
} from '@/utils/tenant-context';
import { ElMessageBoxOptions } from 'element-plus/es/components/message-box/src/message-box.type';
import { NavTypeEnum } from '@/enums/NavTypeEnum';
import Logo from "@/layout/components/Sidebar/Logo.vue";
import TopBar from './TopBar'
import Logo from '@/layout/components/Sidebar/Logo.vue';
import TopBar from './TopBar';
const appStore = useAppStore();
const userStore = useUserStore();
@@ -116,14 +139,14 @@ const newNotice = ref(<number>0);
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const userId = ref(userStore.userId);
const navType = computed(() => settingsStore.navType);
const showLogo = computed(() => settingsStore.sidebarLogo);
const isSuperAdmin = computed(() => userStore.roles.includes('superadmin'));
const companyName = ref(undefined);
const selectedTenantId = ref<string | number>();
const tenantList = ref<TenantVO[]>([]);
// 是否切换了租户
const dynamic = ref(false);
const dynamic = hasDynamicTenant;
// 租户开关
const tenantEnabled = ref(true);
// 搜索菜单
@@ -134,33 +157,97 @@ const openSearchMenu = () => {
};
// 动态切换
const dynamicTenantEvent = async (tenantId: string) => {
if (companyName.value != null && companyName.value !== '') {
await dynamicTenant(tenantId);
dynamic.value = true;
await proxy?.$router.push('/');
await proxy?.$tab.closeAllPage();
await proxy?.$tab.refreshPage();
const dynamicTenantEvent = async (tenantId?: string | number) => {
if (tenantId === undefined || tenantId === null || tenantId === '' || tenantContextSwitching.value) return;
const tenant = tenantList.value.find((item) => String(item.tenantId) === String(tenantId));
if (!tenant) {
selectedTenantId.value = dynamicTenantId.value;
return;
}
try {
await switchDynamicTenantContext({ tenantId, companyName: tenant.companyName }, '/');
} catch {
selectedTenantId.value = dynamicTenantId.value;
}
};
const dynamicClearEvent = async () => {
await dynamicClear();
dynamic.value = false;
await proxy?.$router.push('/');
await proxy?.$tab.closeAllPage();
await proxy?.$tab.refreshPage();
if (tenantContextSwitching.value) return;
try {
await clearDynamicTenantContext('/');
} catch {
selectedTenantId.value = dynamicTenantId.value;
}
};
/** 租户列表 */
const initTenantList = async () => {
const { data } = await getTenantList(true);
tenantEnabled.value = data.tenantEnabled === undefined ? true : data.tenantEnabled;
if (tenantEnabled.value) {
tenantList.value = data.voList;
await waitForTenantRequestContextOwnership();
let contextVersion = dynamicTenantContextVersion.value;
const isCurrentInit = () => isTenantContextVersionCurrent(contextVersion);
let currentTenantId: string | number | null | undefined;
let dynamicContextLoaded = false;
// Read the page-scoped server context first. Without this echo, a restored
// dynamic tenant deliberately rejects the tenant list request as stale.
if (isSuperAdmin.value) {
try {
currentTenantId = (await getDynamicTenant()).data;
if (!isCurrentInit()) return;
restoreDynamicTenantContext(
currentTenantId !== undefined && currentTenantId !== null && currentTenantId !== '' ? { tenantId: currentTenantId } : undefined
);
contextVersion = dynamicTenantContextVersion.value;
dynamicContextLoaded = true;
} catch {
if (!isCurrentInit()) return;
}
}
try {
const { data } = await getTenantList(true);
if (!isCurrentInit()) return;
tenantEnabled.value = data.tenantEnabled === undefined ? true : data.tenantEnabled;
if (!tenantEnabled.value || !isSuperAdmin.value) {
tenantList.value = [];
clearDynamicTenantContextState();
return;
}
tenantList.value = data.voList.filter((tenant) => tenant.status === undefined || tenant.status === '0');
} catch {
if (!isCurrentInit()) return;
tenantList.value = [];
markTenantContextUnverified();
return;
}
if (!dynamicContextLoaded) {
markTenantContextUnverified();
return;
}
const tenant = tenantList.value.find((item) => String(item.tenantId) === String(currentTenantId));
if (currentTenantId !== undefined && currentTenantId !== null && currentTenantId !== '' && !tenant) {
markTenantContextUnverified();
return;
}
restoreDynamicTenantContext(
currentTenantId !== undefined && currentTenantId !== null && currentTenantId !== ''
? { tenantId: currentTenantId, companyName: tenant?.companyName }
: undefined
);
};
watch(
dynamicTenantId,
(tenantId) => {
selectedTenantId.value = tenantId;
},
{ immediate: true }
);
defineExpose({
initTenantList
});
@@ -282,7 +369,6 @@ watch(
margin-left: 8px;
}
.errLog-container {
display: inline-block;
vertical-align: top;
@@ -310,7 +396,9 @@ watch(
&.hover-effect {
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
transition:
background 0.2s ease,
color 0.2s ease;
&:hover {
background: var(--el-fill-color-light);
+24 -17
View File
@@ -1,17 +1,14 @@
<template>
<div
class="sidebar-logo-container"
:class="{ collapse: collapse }"
>
<div class="sidebar-logo-container" :class="{ collapse: collapse }">
<transition :enter-active-class="proxy?.animate.logoAnimate.enter" mode="out-in">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" />
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" :aria-label="title" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" alt="" />
<h1 v-else class="sidebar-title">
{{ title }}
</h1>
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" />
<router-link v-else key="expand" class="sidebar-logo-link" :aria-label="title" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" alt="" />
<h1 class="sidebar-title">
{{ title }}
</h1>
@@ -41,24 +38,24 @@ const sideTheme = computed(() => settingsStore.sideTheme);
// 获取Logo背景色
const getLogoBackground = computed(() => {
if (settingsStore.isDark) {
return 'var(--sidebar-bg)'
return 'var(--sidebar-bg)';
}
if (settingsStore.navType == NavTypeEnum.TOP) {
return variables.menuLightBackground
return variables.menuLightBackground;
}
return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBackground
})
return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBackground;
});
// 获取Logo文字颜色
const getLogoTextColor = computed(() => {
if (settingsStore.isDark) {
return 'var(--sidebar-text)'
return 'var(--sidebar-text)';
}
if (settingsStore.navType == NavTypeEnum.TOP) {
return variables.logoLightTitleColor
return variables.logoLightTitleColor;
}
return sideTheme.value === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor
})
return sideTheme.value === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor;
});
</script>
<style lang="scss" scoped>
@@ -83,6 +80,11 @@ const getLogoTextColor = computed(() => {
height: 100%;
width: 100%;
&:focus-visible {
outline: 2px solid #236fd7;
outline-offset: -3px;
}
& .sidebar-logo {
width: 32px;
height: 32px;
@@ -98,7 +100,12 @@ const getLogoTextColor = computed(() => {
font-weight: 600;
line-height: 50px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
font-family:
Avenir,
Helvetica Neue,
Arial,
Helvetica,
sans-serif;
vertical-align: middle;
}
}
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { canAccessMatchedRoutes, normalizeOperationPath, operationsConsoleRoles, tenantGovernanceRoles } from '@/router/access';
const frontendRoot = existsSync(resolve(process.cwd(), 'src')) ? process.cwd() : resolve(process.cwd(), 'frontend');
const src = (...parts: string[]) => readFileSync(resolve(frontendRoot, 'src', ...parts), 'utf8');
describe('operations sidebar navigation contract', () => {
it('uses native links while retaining the operations role boundary', () => {
const sidebar = src('layout', 'components', 'Sidebar', 'index.vue');
const router = src('router', 'index.ts');
const guard = src('permission.ts');
expect(sidebar).toContain('<router-link');
expect(sidebar).toContain('canAccessOperations');
expect(sidebar).toContain('operationsConsoleRoles');
expect(router).toContain("export { operationsConsoleRoles, tenantGovernanceRoles } from './access';");
expect(router).toContain('roles: operationsConsoleRoles');
expect(guard).toContain('canAccessRoute(to)');
expect(sidebar).toContain('aria-labelledby');
expect(sidebar).not.toContain('<el-menu-item');
expect(sidebar).not.toContain('<transition');
});
it('keeps the shell control and brand link keyboard visible', () => {
const hamburger = src('components', 'Hamburger', 'index.vue');
const logo = src('layout', 'components', 'Sidebar', 'Logo.vue');
expect(hamburger).toContain('<button');
expect(hamburger).toContain('aria-expanded');
expect(hamburger).toContain('aria-controls="main-navigation"');
expect(logo).toContain('&:focus-visible');
expect(logo).toContain(':aria-label="title"');
});
it('enforces the operations roles and canonicalizes the legacy admin route', () => {
const protectedRoute = [{ meta: { roles: operationsConsoleRoles } }];
expect(canAccessMatchedRoutes(['superadmin'], protectedRoute)).toBe(true);
expect(canAccessMatchedRoutes(['admin'], protectedRoute)).toBe(false);
expect(canAccessMatchedRoutes(['employee'], protectedRoute)).toBe(false);
expect(canAccessMatchedRoutes(['employee'], [{ meta: {} }])).toBe(true);
expect(canAccessMatchedRoutes(['employee'], [{ meta: { roles: [] } }])).toBe(false);
expect(normalizeOperationPath('/admin/interview')).toBe('/recruit/interview');
});
it('keeps tenant management out of the HR operator navigation boundary', () => {
const router = src('router', 'index.ts');
const sidebar = src('layout', 'components', 'Sidebar', 'index.vue');
const tenantPage = src('views', 'system', 'tenant', 'index.vue');
expect(router).toContain("path: 'tenant'");
expect(router).toContain("title: '租户管理'");
expect(router).toContain('roles: tenantGovernanceRoles');
expect(tenantGovernanceRoles).toEqual(['superadmin']);
expect(sidebar).toContain('hasRequiredRouteRole(userStore.roles, child.meta?.roles)');
expect(tenantPage).toContain('租户 → 知识空间 → 分类 → 知识');
expect(tenantPage).toContain('switchDynamicTenantContext');
expect(tenantPage).toContain("'/knowledge/spaces'");
expect(tenantPage).toContain('tenantContextSwitching');
const navbar = src('layout', 'components', 'Navbar.vue');
expect(navbar).toContain('switchDynamicTenantContext');
expect(navbar).toContain('getDynamicTenant');
expect(navbar.indexOf('await waitForTenantRequestContextOwnership()')).toBeLessThan(
navbar.indexOf('currentTenantId = (await getDynamicTenant()).data')
);
expect(navbar.indexOf('currentTenantId = (await getDynamicTenant()).data')).toBeLessThan(navbar.indexOf('await getTenantList(true)'));
expect(navbar).toContain("tenant.status === undefined || tenant.status === '0'");
expect(navbar).toContain("currentTenantId !== undefined && currentTenantId !== null && currentTenantId !== '' && !tenant");
const context = src('utils', 'tenant-context.ts');
const contextState = src('utils', 'tenant-context-state.ts');
expect(context).toContain('await tab.closeAllPage()');
expect(context).toContain('await tab.refreshPage()');
expect(context).toContain('dynamicTenantContextVersion');
expect(context).toContain('beginTenantContextOperation');
expect(context).toContain('isTenantContextOperationCurrent');
expect(contextState).toContain('clearDynamicTenantContextState');
expect(contextState).toContain('currentTenantRequestContextId');
expect(contextState).toContain('tenantContextVerified');
expect(contextState).toContain('window.sessionStorage');
expect(contextState).toContain('resetTenantRequestContextId');
expect(contextState).toContain('guardAgainstClonedTabContext');
expect(contextState).toContain("new BroadcastChannel('aihr-tenant-page-context')");
expect(contextState).toContain('tenantContextOwnershipReady');
expect(contextState).toContain('event.persisted');
expect(contextState).toContain("window.addEventListener('pageshow', probeOnPageShow)");
expect(src('utils', 'request.ts')).toContain('dynamicTenantContextHeader');
expect(src('utils', 'request.ts')).toContain('expectedTenantHeader');
expect(src('utils', 'request.ts')).toContain('租户上下文已变更,请刷新后重试');
expect(src('utils', 'request.ts')).toContain('reconcileTenantContextResponse');
expect(src('components', 'FileUpload', 'index.vue')).toContain('reconcileTenantContextResponse(res)');
expect(src('components', 'ImageUpload', 'index.vue')).toContain('reconcileTenantContextResponse(res)');
expect(src('store', 'modules', 'user.ts')).toContain('clearDynamicTenantContextState()');
expect(canAccessMatchedRoutes(['hr_operator'], [{ meta: { roles: operationsConsoleRoles } }, { meta: { roles: tenantGovernanceRoles } }])).toBe(
false
);
});
});
+177 -46
View File
@@ -2,59 +2,98 @@
<div :class="{ 'has-logo': showLogo }" :style="{ backgroundColor: bgColor }">
<logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar :class="sideTheme" wrap-class="scrollbar-wrapper">
<transition :enter-active-class="proxy?.animate.menuSearchAnimate.enter" mode="out-in">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="bgColor"
:text-color="textColor"
:unique-opened="true"
:active-text-color="theme"
:collapse-transition="false"
:popper-offset="12"
mode="vertical"
>
<sidebar-item v-for="(r, index) in sidebarRouters" :key="r.path + index" :item="r" :base-path="r.path" />
</el-menu>
</transition>
<nav
id="main-navigation"
class="operations-menu"
:class="{
'operations-menu--dark': sideTheme === 'theme-dark',
'operations-menu--collapse': isCollapse
}"
aria-label="主导航"
>
<ul class="operations-menu-list">
<li v-if="overview">
<router-link
class="operations-menu-link"
:class="{ 'is-active': isActive(overview.path) }"
:to="overview.path"
:aria-label="overview.title"
:title="isCollapse || overview.title.length > 5 ? overview.title : undefined"
:aria-current="isActive(overview.path) ? 'page' : undefined"
>
<svg-icon :icon-class="overview.icon" />
<span v-if="!isCollapse" class="menu-title">{{ overview.title }}</span>
</router-link>
</li>
<li v-for="group in sidebarGroups" :key="group.title" class="operations-menu-group">
<p v-if="!isCollapse" :id="groupId(group.title)" class="operations-menu-group__title">{{ group.title }}</p>
<ul :aria-labelledby="isCollapse ? undefined : groupId(group.title)">
<li v-for="item in group.items" :key="item.path">
<router-link
class="operations-menu-link"
:class="{ 'is-active': isActive(item.path) }"
:to="item.path"
:aria-label="item.title"
:title="isCollapse || item.title.length > 5 ? item.title : undefined"
:aria-current="isActive(item.path) ? 'page' : undefined"
>
<svg-icon :icon-class="item.icon" />
<span v-if="!isCollapse" class="menu-title">{{ item.title }}</span>
</router-link>
</li>
</ul>
</li>
</ul>
</nav>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import Logo from './Logo.vue';
import SidebarItem from './SidebarItem.vue';
import variables from '@/assets/styles/variables.module.scss';
import { useAppStore } from '@/store/modules/app';
import { useSettingsStore } from '@/store/modules/settings';
import { usePermissionStore } from '@/store/modules/permission';
import { RouteRecordRaw } from 'vue-router';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
import { useUserStore } from '@/store/modules/user';
import { constantRoutes } from '@/router';
import { hasRequiredRouteRole, normalizeOperationPath, operationsConsoleRoles } from '@/router/access';
import { getNormalPath } from '@/utils/ruoyi';
import type { RouteRecordRaw } from 'vue-router';
const route = useRoute();
const appStore = useAppStore();
const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore();
const demoMenuTitles = new Set([
'首页',
'AI面试',
'训练运营',
'三角色对练',
'复盘校准',
'案例沉淀',
'知识空间',
'SOP知识库',
'资料处理',
'模型配置',
'全网AI配置',
'组织与权限',
'成长激励',
'问题榜运营'
]);
const getRouteMenuTitle = (route: RouteRecordRaw) => route.children?.find((child) => !child.hidden)?.meta?.title || route.meta?.title;
const sidebarRouters = computed<RouteRecordRaw[]>(() =>
permissionStore.getSidebarRoutes().filter((route) => demoMenuTitles.has(String(getRouteMenuTitle(route))))
const userStore = useUserStore();
type SidebarLeaf = { title: string; path: string; icon: string };
type SidebarGroup = { title: string; items: SidebarLeaf[] };
const groupTitles = ['训练运营', '工作成果', '知识运营', '内容运营', '共创运营', '招聘与组织', '平台设置'];
const routeTitle = (route: RouteRecordRaw) => String(route.meta?.title || '');
const normalizeRoutePath = (path: string) => {
if (/^https?:\/\//.test(path)) return path;
return normalizeOperationPath(getNormalPath(path));
};
const toLeaf = (parent: RouteRecordRaw, child: RouteRecordRaw): SidebarLeaf => ({
title: routeTitle(child),
path: normalizeRoutePath(child.path.startsWith('/') ? child.path : `${parent.path}/${child.path}`),
icon: String(child.meta?.icon || parent.meta?.icon || 'list')
});
const visibleChildren = (route: RouteRecordRaw) =>
(route.children || []).filter((child) => !child.hidden && routeTitle(child) && hasRequiredRouteRole(userStore.roles, child.meta?.roles));
const canAccessOperations = computed(() => userStore.roles.some((role) => operationsConsoleRoles.includes(role)));
const groupId = (title: string) => `sidebar-group-${groupTitles.indexOf(title)}`;
const sidebarRoutes = computed(() => (canAccessOperations.value ? constantRoutes : []));
const overview = computed<SidebarLeaf | undefined>(() => {
const parent = sidebarRoutes.value.find((route) => visibleChildren(route).some((child) => routeTitle(child) === '运营总览'));
const child = parent && visibleChildren(parent).find((item) => routeTitle(item) === '运营总览');
return parent && child ? toLeaf(parent, child) : undefined;
});
const sidebarGroups = computed<SidebarGroup[]>(() =>
groupTitles.flatMap((title) => {
const parent = sidebarRoutes.value.find((route) => routeTitle(route) === title);
const items = parent ? visibleChildren(parent).map((child) => toLeaf(parent, child)) : [];
return items.length ? [{ title, items }] : [];
})
);
const showLogo = computed(() => settingsStore.sidebarLogo);
const sideTheme = computed(() => settingsStore.sideTheme);
@@ -62,14 +101,106 @@ const theme = computed(() => settingsStore.theme);
const isCollapse = computed(() => !appStore.sidebar.opened);
const activeMenu = computed(() => {
const { meta, path } = route;
const { meta, path, matched } = route;
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu;
}
return path;
const matchedPath = matched[matched.length - 1]?.path;
return normalizeRoutePath(typeof meta.activeMenu === 'string' ? meta.activeMenu : matchedPath || path);
});
const isActive = (path: string) => activeMenu.value === normalizeRoutePath(path);
const bgColor = computed(() => (sideTheme.value === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground));
const textColor = computed(() => (sideTheme.value === 'theme-dark' ? variables.menuColor : variables.menuLightColor));
</script>
<style scoped>
.operations-menu {
min-height: 100%;
background: v-bind(bgColor);
}
.operations-menu-list,
.operations-menu-group ul {
padding: 0;
margin: 0;
list-style: none;
}
#app .sidebar-container .operations-menu-link {
display: flex;
align-items: center;
width: calc(100% - 10px);
height: 45px;
padding: 0 18px;
margin: 1px 5px;
overflow: hidden;
border-radius: var(--app-radius-md);
color: rgba(0, 0, 0, 0.7);
font-size: 14px;
line-height: 45px;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
transition:
background-color 0.18s ease,
color 0.18s ease;
&:hover {
background: rgba(0, 0, 0, 0.05);
}
&.is-active {
background: v-bind(theme);
color: #fff;
}
&:focus-visible {
outline: 2px solid #fff;
outline-offset: -3px;
box-shadow: inset 0 0 0 4px #1f2d3d;
}
}
#app .sidebar-container .operations-menu-link .svg-icon {
flex: 0 0 auto;
margin-right: 10px;
}
#app .sidebar-container .operations-menu--dark .operations-menu-link {
color: var(--menuColor);
&:hover {
background: var(--menuHover);
}
&.is-active {
color: #fff;
}
}
.operations-menu-group__title {
padding: 18px 20px 7px;
margin: 0;
color: #536579;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
}
.operations-menu--dark .operations-menu-group__title {
color: #aeb9cb;
}
#app .sidebar-container .operations-menu--collapse .operations-menu-link {
justify-content: center;
width: 44px;
padding: 0;
}
#app .sidebar-container .operations-menu--collapse .operations-menu-link .svg-icon {
margin-right: 0;
}
:deep(.scrollbar-wrapper:focus-visible) {
outline: 2px solid #236fd7;
outline-offset: -2px;
}
</style>