feat(aihr): 重构运营后台与租户知识治理
- 重构运营总览、侧栏和内容运营工作台\n- 增加大喇叭、成果审核与运营统计链路\n- 补齐租户、知识空间、分类和知识维护闭环\n- 加固标签页租户上下文、停用租户写保护与迁移预检
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user