395 lines
10 KiB
Vue
395 lines
10 KiB
Vue
<!--
|
|
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>
|