feat(auth): 后台登录、角色权限与会话访问策略
管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
1 parent
9eb8613c05
commit
556b650c7a
123 files changed
+25293
No files matched your search
@@ -0,0 +1,352 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts" name="AppSidebar">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useNavItems, navIconSvg } from './navItems'
|
||||
|
||||
import SidebarToggle from './SidebarToggle.vue'
|
||||
|
||||
defineProps<{
|
||||
isCollapsed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
(e: 'navigate'): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { navItems } = useNavItems()
|
||||
|
||||
const isActiveRoute = computed(() => (path?: string) => path ? route.path === path : false)
|
||||
|
||||
const handleNavigation = () => {
|
||||
emit('navigate')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ 'collapsed': isCollapsed }">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-container">
|
||||
<div class="logo-mark" aria-hidden="true">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
<span v-if="!isCollapsed" class="logo-text">ReplyPier</span>
|
||||
</div>
|
||||
<SidebarToggle :isCollapsed="isCollapsed" @toggle="emit('toggle')" />
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<div v-for="(item, index) in navItems" :key="index">
|
||||
<!-- Section Header -->
|
||||
<div v-if="item.section" class="nav-section nav-section-heading" :class="{ 'collapsed': isCollapsed }">
|
||||
<span v-if="!isCollapsed">{{ item.section }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Nav Item -->
|
||||
<router-link v-else-if="item.to" :to="item.to" class="nav-item"
|
||||
:class="{ 'active': isActiveRoute(item.to) }"
|
||||
:title="isCollapsed ? item.label : undefined"
|
||||
@click="handleNavigation">
|
||||
<span class="nav-bar" aria-hidden="true"></span>
|
||||
<span class="nav-icon" v-html="navIconSvg(item.icon)"></span>
|
||||
<span v-if="!isCollapsed" class="nav-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 252px;
|
||||
background: var(--bg2);
|
||||
border-right: 1px solid var(--o07);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width .22s ease;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 14px;
|
||||
border-bottom: 1px solid var(--o06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Collapsed header: stack logo above the toggle, both centered */
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 3-dot logo mark */
|
||||
.logo-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--accent-solid);
|
||||
border-radius: 10px 10px 10px 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 4.5px;
|
||||
height: 4.5px;
|
||||
background: var(--on-accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 18px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* Hide the scrollbar so it doesn't squeeze the centered icons when collapsed */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Collapsed: tighten side padding so icons sit dead-centre in the 76px rail */
|
||||
.sidebar.collapsed .sidebar-nav {
|
||||
padding: 18px 8px;
|
||||
}
|
||||
|
||||
/* Typography comes from the shared .nav-section-heading (components.css) */
|
||||
.nav-section {
|
||||
padding: 0 13px;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
|
||||
/* Collapsed: section label becomes an invisible spacer, no divider line */
|
||||
.nav-section.collapsed {
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 11px 13px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-btn);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Collapsed: center the icon, drop the gap */
|
||||
.sidebar.collapsed .nav-item {
|
||||
padding: 11px;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-item:hover:not(.active) {
|
||||
background: var(--o04);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.nav-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* Lime accent bar on the active item */
|
||||
.nav-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-solid);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hide the active bar when collapsed */
|
||||
.sidebar.collapsed .nav-item.active .nav-bar {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.nav-icon :deep(svg) {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Small laptops (1025px - 1280px) */
|
||||
@media (max-width: 1280px) and (min-width: 1025px) {
|
||||
.sidebar {
|
||||
width: 248px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 76px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablets and below - Overlay mode */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 8px 0 32px rgba(0,0,0,.4);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
width: 252px;
|
||||
/* An off-canvas panel must not keep painting its shadow over the page.
|
||||
Sliding it out moves the box, not the shadow's reach: with an 8px
|
||||
offset and 32px blur, roughly 24px of it still lands on the page's
|
||||
left edge, above the content at z-index 1000. On the dark theme that
|
||||
is black on near-black and invisible, which is why it went unnoticed;
|
||||
on the light theme it reads as a grey band down the side. */
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* In overlay mode the panel is full-width — restore expanded layout */
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 22px 20px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
padding: 11px 13px;
|
||||
gap: 13px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small mobile */
|
||||
@media (max-width: 480px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: var(--space-md) var(--space-lg) var(--space-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts" name="BottomNav">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useNavItems, navIconSvg, formatBadgeCount } from './navItems'
|
||||
|
||||
const props = defineProps<{
|
||||
unreadCount?: number
|
||||
moreOpen?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'more'): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { primaryNavItems, moreNavItems } = useNavItems()
|
||||
|
||||
const isActive = (path?: string) => !!path && route.path.startsWith(path)
|
||||
|
||||
// More is "active" while its sheet is open or when the current page lives in it
|
||||
const moreActive = computed(() =>
|
||||
props.moreOpen || moreNavItems.value.some(item => isActive(item.to))
|
||||
)
|
||||
|
||||
const badgeText = computed(() => formatBadgeCount(props.unreadCount))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav" aria-label="主导航">
|
||||
<router-link
|
||||
v-for="item in primaryNavItems"
|
||||
:key="item.to"
|
||||
:to="item.to!"
|
||||
class="bottom-nav-item"
|
||||
:class="{ active: isActive(item.to) }"
|
||||
>
|
||||
<span class="bottom-nav-icon" v-html="navIconSvg(item.icon, 24)"></span>
|
||||
<span class="bottom-nav-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bottom-nav-item"
|
||||
:class="{ active: moreActive }"
|
||||
aria-label="更多"
|
||||
@click="emit('more')"
|
||||
>
|
||||
<span class="bottom-nav-icon" v-html="navIconSvg('more', 24)"></span>
|
||||
<span class="bottom-nav-label">更多</span>
|
||||
<span v-if="unreadCount" class="bottom-nav-badge">{{ badgeText }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-bottom-nav);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: var(--bg2);
|
||||
border-top: 1px solid var(--o07);
|
||||
padding: 6px 14px calc(6px + var(--safe-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 2px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.bottom-nav-item.active {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bottom-nav-icon :deep(svg) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.bottom-nav-label {
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bottom-nav-item.active .bottom-nav-label {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.bottom-nav-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: calc(50% + 6px);
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 4px;
|
||||
background: var(--c-danger);
|
||||
color: var(--on-dark);
|
||||
border: 1.5px solid var(--bg2);
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts" name="MoreSheet">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNavItems, navIconSvg, formatBadgeCount } from './navItems'
|
||||
import InstallPrompt from '@/components/pwa/InstallPrompt.vue'
|
||||
import type { ThemeMode } from '@/composables/useTheme'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
isOnline?: boolean
|
||||
statusUpdating?: boolean
|
||||
themeMode?: ThemeMode
|
||||
unreadCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toggle-status'): void
|
||||
(e: 'toggle-theme'): void
|
||||
(e: 'notifications'): void
|
||||
(e: 'logout'): void
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { moreNavGroups } = useNavItems()
|
||||
|
||||
const themeLabel = computed(() =>
|
||||
props.themeMode === 'dark' ? '深色' : props.themeMode === 'light' ? '浅色' : '跟随系统'
|
||||
)
|
||||
|
||||
const themeIconName = computed(() =>
|
||||
props.themeMode === 'dark' ? 'moon' : props.themeMode === 'light' ? 'sun' : 'monitor'
|
||||
)
|
||||
|
||||
const badgeText = computed(() => formatBadgeCount(props.unreadCount))
|
||||
|
||||
const navigate = (to?: string) => {
|
||||
if (!to) return
|
||||
emit('close')
|
||||
router.push(to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="more-sheet">
|
||||
<div v-if="open" class="more-sheet-root">
|
||||
<div class="more-scrim" @click="emit('close')"></div>
|
||||
<div class="more-sheet" role="dialog" aria-label="更多">
|
||||
<div class="drag-handle" aria-hidden="true"></div>
|
||||
|
||||
<!-- Availability -->
|
||||
<button
|
||||
type="button"
|
||||
class="availability-card"
|
||||
:class="{ offline: !isOnline }"
|
||||
:disabled="statusUpdating"
|
||||
@click="emit('toggle-status')"
|
||||
>
|
||||
<span class="availability-dot" :class="{ online: isOnline }"></span>
|
||||
<span class="availability-text">
|
||||
<span class="availability-title">{{ isOnline ? "你当前在线" : "你当前离线" }}</span>
|
||||
<span class="availability-sub">{{ isOnline ? '接收新会话' : '暂停接收新会话' }}</span>
|
||||
</span>
|
||||
<span class="toggle-track" :class="{ on: isOnline }">
|
||||
<span class="toggle-knob"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Theme -->
|
||||
<button type="button" class="sheet-row standalone" @click="emit('toggle-theme')">
|
||||
<span class="row-icon theme-icon" aria-hidden="true" v-html="navIconSvg(themeIconName, 20)"></span>
|
||||
<span class="row-label">主题</span>
|
||||
<span class="row-value">{{ themeLabel }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Overflow nav links, grouped exactly as the desktop sidebar -->
|
||||
<template v-for="(group, index) in moreNavGroups" :key="group.section || index">
|
||||
<div v-if="group.section" class="sheet-section nav-section-heading">{{ group.section }}</div>
|
||||
<div class="sheet-card">
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.to"
|
||||
type="button"
|
||||
class="sheet-row"
|
||||
@click="navigate(item.to)"
|
||||
>
|
||||
<span class="row-icon" v-html="navIconSvg(item.icon, 20)"></span>
|
||||
<span class="row-label">{{ item.label }}</span>
|
||||
<span class="row-chevron" aria-hidden="true" v-html="navIconSvg('chevronRight', 18)"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Notifications -->
|
||||
<button type="button" class="sheet-row standalone" @click="emit('notifications')">
|
||||
<span class="row-icon" v-html="navIconSvg('bell', 20)"></span>
|
||||
<span class="row-label">通知</span>
|
||||
<span v-if="unreadCount" class="row-badge">{{ badgeText }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Install as app (hidden once installed) -->
|
||||
<div class="install-slot">
|
||||
<InstallPrompt />
|
||||
</div>
|
||||
|
||||
<!-- Logout -->
|
||||
<button type="button" class="logout-row" @click="emit('logout')">
|
||||
<span class="row-icon" v-html="navIconSvg('logout', 19)"></span>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.more-sheet-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-sheet);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.more-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--scrim);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.more-sheet {
|
||||
position: relative;
|
||||
background: var(--bg2);
|
||||
border-top: 1px solid var(--o07);
|
||||
border-top-left-radius: 24px;
|
||||
border-top-right-radius: 24px;
|
||||
padding: 12px 18px calc(var(--space-lg) + var(--safe-bottom));
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-height: calc(100dvh - 60px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
width: 38px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--o10);
|
||||
margin: 0 auto 18px;
|
||||
}
|
||||
|
||||
.availability-card {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: var(--success-color-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--success-color) 35%, transparent);
|
||||
margin-bottom: 14px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.availability-card.offline {
|
||||
background: var(--o04);
|
||||
border-color: var(--o10);
|
||||
}
|
||||
|
||||
.availability-card:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.availability-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.availability-dot.online {
|
||||
background: var(--success-color);
|
||||
box-shadow: 0 0 0 4px var(--success-color-soft);
|
||||
}
|
||||
|
||||
.availability-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.availability-title {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.availability-sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
border-radius: 16px;
|
||||
background: var(--toggle-track-off);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle-track.on {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--toggle-knob);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle-track.on .toggle-knob {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Typography comes from the shared .nav-section-heading (components.css) */
|
||||
.sheet-section {
|
||||
padding: 0 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sheet-card {
|
||||
background: var(--o04);
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sheet-card .sheet-row + .sheet-row {
|
||||
border-top: 1px solid var(--o06);
|
||||
}
|
||||
|
||||
.sheet-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 14px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color var(--transition-fast);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.sheet-row:hover {
|
||||
background: var(--o05);
|
||||
}
|
||||
|
||||
.sheet-row.standalone {
|
||||
background: var(--o04);
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.row-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-value {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.row-chevron {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.row-badge {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 11px;
|
||||
background: var(--c-coral);
|
||||
color: var(--on-light);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.install-slot {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.install-slot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logout-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
padding: 13px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--c-coral);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout-row .row-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Slide-up transition */
|
||||
.more-sheet-enter-active,
|
||||
.more-sheet-leave-active {
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.more-sheet-enter-active .more-sheet,
|
||||
.more-sheet-leave-active .more-sheet {
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.more-sheet-enter-from,
|
||||
.more-sheet-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.more-sheet-enter-from .more-sheet,
|
||||
.more-sheet-leave-to .more-sheet {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isCollapsed: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="toggle-btn" @click="$emit('toggle')">
|
||||
<span class="toggle-icon">{{ isCollapsed ? '▶' : '◀' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toggle-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast),
|
||||
border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: var(--o10);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.toggle-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Shared inline stroke icon set (stroke="currentColor" so glyphs inherit the
|
||||
// surrounding color — muted by default, lime when active). One source of truth
|
||||
// for the sidebar, bottom nav, More sheet, and prompts.
|
||||
export const NAV_ICONS: Record<string, string> = {
|
||||
agents: '<rect x="5" y="8" width="14" height="11" rx="3"/><line x1="12" y1="4" x2="12" y2="8"/><circle cx="9" cy="13" r="1" fill="currentColor"/><circle cx="15" cy="13" r="1" fill="currentColor"/>',
|
||||
humans: '<circle cx="9" cy="8" r="3"/><path d="M3.5 19a5.5 5 0 0 1 11 0"/><circle cx="16.5" cy="9" r="2.3"/><path d="M15 19a4.5 4 0 0 1 5.5-3.6"/>',
|
||||
inbox: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M3 13h5l1.5 2.5h4L19 13h2"/>',
|
||||
people: '<circle cx="9" cy="8" r="2.6"/><path d="M4 18a5 4.5 0 0 1 10 0"/><path d="M15.5 6.2a2.6 2.6 0 0 1 0 4.6"/><path d="M16 13.6A5 4.5 0 0 1 20 18"/>',
|
||||
analytics: '<line x1="5" y1="17" x2="5" y2="13"/><line x1="10" y1="17" x2="10" y2="9"/><line x1="15" y1="17" x2="15" y2="6"/><line x1="20" y1="17" x2="20" y2="11"/>',
|
||||
knowledge: '<path d="M4 5.5A2 2 0 0 1 6 4h5v16H6a2 2 0 0 0-2 1.5z"/><path d="M20 5.5A2 2 0 0 0 18 4h-5v16h5a2 2 0 0 1 2 1.5z"/>',
|
||||
faq: '<circle cx="12" cy="12" r="9"/><path d="M9.3 9.3a2.7 2.7 0 0 1 5.2 1c0 1.8-2.5 2-2.5 3.3"/><circle cx="12" cy="17" r=".6" fill="currentColor"/>',
|
||||
tickets: '<path d="M4 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2 2 2 0 0 0 0 4 2 2 0 0 1-2 2H6a2 2 0 0 1-2-2 2 2 0 0 0 0-4z"/><line x1="14" y1="8" x2="14" y2="16" stroke-dasharray="2 2.4"/>',
|
||||
ticketing: '<circle cx="12" cy="12" r="3"/><path d="M12 4v2M12 18v2M4 12h2M18 12h2M6 6l1.5 1.5M16.5 16.5 18 18M18 6l-1.5 1.5M7.5 16.5 6 18"/>',
|
||||
org: '<rect x="4" y="4" width="14" height="16" rx="2"/><line x1="20" y1="20" x2="20" y2="11"/><line x1="18" y1="11" x2="22" y2="11"/><circle cx="8" cy="9" r=".6" fill="currentColor"/><circle cx="12" cy="9" r=".6" fill="currentColor"/><circle cx="8" cy="13" r=".6" fill="currentColor"/><circle cx="12" cy="13" r=".6" fill="currentColor"/>',
|
||||
subscription: '<rect x="3" y="6" width="18" height="12" rx="2.5"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="7" y1="14" x2="11" y2="14"/>',
|
||||
integrations: '<line x1="12" y1="12" x2="6" y2="6"/><line x1="12" y1="12" x2="18" y2="6"/><line x1="12" y1="12" x2="12" y2="19"/><circle cx="12" cy="12" r="2.2" fill="currentColor"/><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="19" r="2"/>',
|
||||
widgets: '<rect x="4" y="4" width="7" height="7" rx="2"/><rect x="13" y="4" width="7" height="7" rx="2"/><rect x="4" y="13" width="7" height="7" rx="2"/><circle cx="16.5" cy="16.5" r="3.5"/>',
|
||||
aiconfig: '<line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="9" cy="8" r="2.4"/><circle cx="15" cy="16" r="2.4"/>',
|
||||
usersettings: '<circle cx="12" cy="8" r="3.4"/><path d="M5.5 19a6.5 5.5 0 0 1 13 0"/>',
|
||||
more: '<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/>',
|
||||
bell: '<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9z"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>',
|
||||
logout: '<path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2"/><path d="M10 12h11m0 0-3-3m3 3-3 3"/>',
|
||||
chevronRight: '<path d="M9 6l6 6-6 6"/>',
|
||||
moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
|
||||
sun: '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>',
|
||||
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||
}
|
||||
|
||||
// Icons are static — cache built strings so hot paths (bottom nav re-renders
|
||||
// on every route change) don't rebuild them.
|
||||
const svgCache = new Map<string, string>()
|
||||
|
||||
export const navIconSvg = (name?: string, size = 19) => {
|
||||
const key = `${name}:${size}`
|
||||
let svg = svgCache.get(key)
|
||||
if (!svg) {
|
||||
svg = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${name === 'analytics' ? 2 : 1.7}" stroke-linecap="round" stroke-linejoin="round">${name ? (NAV_ICONS[name] || '') : ''}</svg>`
|
||||
svgCache.set(key, svg)
|
||||
}
|
||||
return svg
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { permissionChecks } from '@/utils/permissions'
|
||||
// Visibility comes from the same map the router enforces, so a link can never
|
||||
// appear for a page the guard will refuse (People used to do exactly that).
|
||||
import { canAccessPath } from '@/router/routePermissions'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
|
||||
// Re-exported so nav consumers keep a single import site
|
||||
export { NAV_ICONS, navIconSvg } from './navIcons'
|
||||
|
||||
export interface NavItem {
|
||||
to?: string
|
||||
icon?: string
|
||||
label?: string
|
||||
section?: string
|
||||
show?: boolean
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
section: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
// Shared unread-badge cap (bottom nav, More sheet, header bell)
|
||||
export const formatBadgeCount = (count?: number) =>
|
||||
count && count > 99 ? '99+' : String(count || '')
|
||||
|
||||
// Bottom-nav primary slots, in display order (remaining links go to the More sheet)
|
||||
export const PRIMARY_NAV_PATHS = ['/conversations', '/people', '/ai-agents', '/analytics']
|
||||
|
||||
export function useNavItems() {
|
||||
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||||
|
||||
// Section membership is explicit rather than inferred from array position:
|
||||
// a header can be permission-hidden while one of its items is not (User
|
||||
// Settings is always visible), which silently orphaned items into the
|
||||
// preceding section.
|
||||
const navGroups = computed<NavGroup[]>(() =>
|
||||
[
|
||||
{
|
||||
section: '主菜单',
|
||||
items: [
|
||||
{
|
||||
to: '/ai-agents',
|
||||
icon: 'agents',
|
||||
label: 'AI 客服',
|
||||
show: canAccessPath('/ai-agents'),
|
||||
},
|
||||
{
|
||||
to: '/human-agents',
|
||||
icon: 'humans',
|
||||
label: '人工客服',
|
||||
show: canAccessPath('/human-agents'),
|
||||
},
|
||||
{
|
||||
to: '/conversations',
|
||||
icon: 'inbox',
|
||||
label: '会话',
|
||||
show: canAccessPath('/conversations'),
|
||||
},
|
||||
{
|
||||
to: '/tickets',
|
||||
icon: 'tickets',
|
||||
label: '工单',
|
||||
show: canAccessPath('/tickets'),
|
||||
},
|
||||
{
|
||||
to: '/people',
|
||||
icon: 'people',
|
||||
label: '联系人',
|
||||
show: canAccessPath('/people'),
|
||||
},
|
||||
{
|
||||
to: '/knowledge',
|
||||
icon: 'knowledge',
|
||||
label: '知识库',
|
||||
show: canAccessPath('/knowledge'),
|
||||
},
|
||||
{
|
||||
to: '/faq',
|
||||
icon: 'faq',
|
||||
label: '帮助中心',
|
||||
show: canAccessPath('/faq'),
|
||||
},
|
||||
{
|
||||
to: '/analytics',
|
||||
icon: 'analytics',
|
||||
label: '数据分析',
|
||||
show: canAccessPath('/analytics'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '设置',
|
||||
items: [
|
||||
{
|
||||
to: '/settings/organization',
|
||||
icon: 'org',
|
||||
label: '组织',
|
||||
show: canAccessPath('/settings/organization'),
|
||||
},
|
||||
{
|
||||
to: '/settings/subscription',
|
||||
icon: 'subscription',
|
||||
label: '订阅',
|
||||
show: hasEnterpriseModule && permissionChecks.canViewSubscription(),
|
||||
},
|
||||
{
|
||||
to: '/settings/ticketing',
|
||||
icon: 'ticketing',
|
||||
label: '工单设置',
|
||||
show: canAccessPath('/settings/ticketing'),
|
||||
},
|
||||
{
|
||||
to: '/settings/integrations',
|
||||
icon: 'integrations',
|
||||
label: '集成',
|
||||
show: canAccessPath('/settings/integrations'),
|
||||
},
|
||||
{
|
||||
to: '/settings/app-integrations',
|
||||
icon: 'integrations',
|
||||
label: '应用集成',
|
||||
show: hasEnterpriseModule && canAccessPath('/settings/app-integrations'),
|
||||
},
|
||||
{
|
||||
to: '/settings/widget-apps',
|
||||
icon: 'widgets',
|
||||
label: '网站组件',
|
||||
show: canAccessPath('/settings/widget-apps'),
|
||||
},
|
||||
{
|
||||
to: '/settings/ai-config',
|
||||
icon: 'aiconfig',
|
||||
label: 'AI 配置',
|
||||
show: canAccessPath('/settings/ai-config'),
|
||||
},
|
||||
{
|
||||
to: '/settings/user',
|
||||
icon: 'usersettings',
|
||||
label: '个人设置',
|
||||
show: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
.map((group) => ({ ...group, items: group.items.filter((item) => item.show !== false) }))
|
||||
// A section is visible when it has something to show — no separate
|
||||
// permission flag to drift from its items
|
||||
.filter((group) => group.items.length > 0),
|
||||
)
|
||||
|
||||
// Flat list (heading followed by its links) for the desktop sidebar
|
||||
const navItems = computed<NavItem[]>(() =>
|
||||
navGroups.value.flatMap((group) => [{ section: group.section }, ...group.items]),
|
||||
)
|
||||
|
||||
// Bottom-nav slots in design order
|
||||
const primaryNavItems = computed<NavItem[]>(() =>
|
||||
PRIMARY_NAV_PATHS.map((path) => navItems.value.find((item) => item.to === path)).filter(
|
||||
(item): item is NavItem => !!item,
|
||||
),
|
||||
)
|
||||
|
||||
// Everything the bottom nav doesn't carry, still grouped like the sidebar
|
||||
const moreNavGroups = computed<NavGroup[]>(() =>
|
||||
navGroups.value
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => item.to && !PRIMARY_NAV_PATHS.includes(item.to)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0),
|
||||
)
|
||||
|
||||
// Derived from the groups so there is one definition of "overflow link"
|
||||
const moreNavItems = computed<NavItem[]>(() =>
|
||||
moreNavGroups.value.flatMap((group) => group.items),
|
||||
)
|
||||
|
||||
return { navGroups, navItems, primaryNavItems, moreNavItems, moreNavGroups }
|
||||
}
|
||||
Reference in new issue
Block a user