feat(auth): 后台登录、角色权限与会话访问策略

管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
WangShen007 committed 2026-09-24 21:35:00 +08:00
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
}
+197
View File
@@ -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 }
}