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,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>