Files
portal/app/resources/js/components/layout/AppTopbar.vue
T
Дмитрий 9331465c26 fix(layout): меню топбара не уходит за экран при reduced-motion
Активатор v-menu внутри position:fixed v-app-bar уезжает off-screen под
prefers-reduced-motion:reduce (умолчание Windows Server). Подключён
repositionMenuAfterOpen к обоим меню топбара через @update:model-value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:07:47 +03:00

290 lines
9.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): topbar выделен из AppLayout.
* Crumb + search-trigger (заглушка ⌘K) + bell с notifications dropdown + user-chip menu.
* Stores: auth + notifications (используются напрямую, без prop-drilling).
*/
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
import { useNotificationsStore } from '../../stores/notifications';
import { useCommandPalette } from '../../composables/useCommandPalette';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
defineProps<{
pageTitle: string;
}>();
const emit = defineEmits<{
'toggle-drawer': [];
}>();
const auth = useAuthStore();
const notifications = useNotificationsStore();
const router = useRouter();
const { openPalette } = useCommandPalette();
const unreadDisplay = computed(() => {
if (notifications.unreadCount === 0) return '';
if (notifications.unreadCount > 99) return '99+';
return String(notifications.unreadCount);
});
function eventIcon(event: string): string {
const map: Record<string, string> = {
new_lead: 'mdi-account-plus-outline',
reminder: 'mdi-clock-outline',
low_balance: 'mdi-wallet-outline',
zero_balance: 'mdi-alert-circle-outline',
topup_success: 'mdi-cash-plus',
invoice_paid: 'mdi-receipt-text-check-outline',
new_device_login: 'mdi-shield-account-outline',
marketing: 'mdi-bullhorn-outline',
};
return map[event] ?? 'mdi-bell-outline';
}
function formatRelative(iso: string | null): string {
if (!iso) return '';
const ms = Date.now() - new Date(iso).getTime();
const min = Math.floor(ms / 60_000);
if (min < 1) return 'только что';
if (min < 60) return `${min} мин назад`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr} ч назад`;
const days = Math.floor(hr / 24);
return `${days} д назад`;
}
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
await notifications.markRead(id);
if (dealId !== null) {
// Audit F3: deep-link на конкретный drawer через ?openId=.
await router.push({ path: '/deals', query: { openId: dealId } });
}
}
const userInitials = computed(() => {
const u = auth.user;
if (!u) return '?';
const first = u.first_name?.[0] ?? '';
const last = u.last_name?.[0] ?? '';
const initials = (first + last).toUpperCase();
return initials || u.email.slice(0, 2).toUpperCase();
});
const userShortName = computed(() => {
const u = auth.user;
if (!u) return 'Гость';
if (u.first_name && u.last_name) {
return `${u.first_name} ${u.last_name[0]}.`;
}
return u.first_name || u.email;
});
async function handleLogout(): Promise<void> {
await auth.logout();
await router.push('/login');
}
</script>
<template>
<v-app-bar :elevation="0" color="surface" class="app-topbar" :height="56">
<v-app-bar-nav-icon class="d-md-none" aria-label="Открыть меню навигации" @click="emit('toggle-drawer')" />
<div class="crumb">
<strong>{{ pageTitle }}</strong>
</div>
<v-spacer />
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-magnify"
class="searchbar mr-2"
data-testid="topbar-search-btn"
@click="openPalette"
>
Поиск
<template #append>
<kbd class="search-kbd">K</kbd>
</template>
</v-btn>
<v-menu offset="8" :close-on-content-click="false" location="bottom end" @update:model-value="repositionMenuAfterOpen">
<template #activator="{ props: bellProps }">
<v-btn
v-bind="bellProps"
icon
size="small"
variant="text"
aria-label="Уведомления"
data-testid="notifications-btn"
>
<v-icon>mdi-bell-outline</v-icon>
<span
v-if="notifications.unreadCount > 0"
class="notification-pip"
data-testid="notifications-pip"
aria-hidden="true"
>
{{ unreadDisplay }}
</span>
</v-btn>
</template>
<v-card class="notifications-menu" min-width="360" max-width="420" elevation="3">
<div class="notifications-header">
<strong>Уведомления</strong>
<v-btn
v-if="notifications.unreadCount > 0"
size="x-small"
variant="text"
data-testid="mark-all-read-btn"
@click="notifications.markAllRead()"
>
Прочитать все
</v-btn>
</div>
<v-divider />
<div v-if="notifications.items.length === 0" class="notifications-empty">
<v-icon size="32" class="mb-2">mdi-bell-off-outline</v-icon>
<div>Нет уведомлений</div>
</div>
<v-list v-else density="compact" class="notifications-list" data-testid="notifications-list">
<v-list-item
v-for="item in notifications.sortedItems.slice(0, 10)"
:key="item.id"
:class="{ 'notification-unread': item.read_at === null }"
data-testid="notification-item"
@click="handleNotificationClick(item.id, item.deal_id)"
>
<template #prepend>
<v-icon :icon="eventIcon(item.event)" size="20" />
</template>
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
<v-list-item-subtitle class="text-caption">
{{ item.body }}
</v-list-item-subtitle>
<template #append>
<span class="notification-time">{{ formatRelative(item.created_at) }}</span>
</template>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-menu offset="8" @update:model-value="repositionMenuAfterOpen">
<template #activator="{ props }">
<v-btn v-bind="props" variant="text" size="small" class="user-chip ml-2" aria-label="Меню пользователя">
<v-avatar size="28" color="primary" class="mr-2">
<span class="text-caption">{{ userInitials }}</span>
</v-avatar>
<span class="text-body-2">{{ userShortName }}</span>
</v-btn>
</template>
<v-list density="compact" min-width="200">
<v-list-item v-if="auth.user" :title="auth.user.email" disabled />
<v-divider v-if="auth.user" />
<v-list-item :to="'/settings'" prepend-icon="mdi-cog-outline" title="Настройки" />
<v-list-item prepend-icon="mdi-logout" title="Выйти" @click="handleLogout" />
</v-list>
</v-menu>
</v-app-bar>
</template>
<style scoped>
.app-topbar {
background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261e 100%) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
color: #e8e2d4 !important;
}
.app-topbar :deep(.v-toolbar__content) {
padding-left: 240px;
color: #e8e2d4;
}
.app-topbar :deep(.v-icon) {
color: #b8b0a0;
}
.crumb {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
margin-left: 8px;
color: #e8e2d4;
}
.crumb strong {
color: var(--liderra-ivory);
font-weight: 600;
}
.searchbar {
text-transform: none;
color: #b8b0a0 !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
.search-kbd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
padding: 1px 5px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
color: #9b9484;
margin-left: 6px;
}
.user-chip :deep(.v-btn__content) {
color: #e8e2d4;
}
.notification-pip {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 5px;
border-radius: 8px;
background: #b94837;
color: #fff;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 10px;
line-height: 16px;
text-align: center;
font-weight: 600;
}
.notifications-menu {
background: #ffffff;
}
.notifications-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
}
.notifications-empty {
padding: 32px 16px;
text-align: center;
color: #66635c;
font-size: 13px;
}
.notifications-list {
max-height: 420px;
overflow-y: auto;
}
.notification-unread {
background: #f0f8f5;
}
.notification-time {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 11px;
color: #66635c;
white-space: nowrap;
margin-left: 8px;
}
.user-chip {
text-transform: none;
}
</style>