Files
portal/app/resources/js/components/layout/AppTopbar.vue
T
Дмитрий 23c4179aff fix(портал-мобилка): небольшой зазор у «Поиска» в шапке на телефоне
При длинном заголовке («Конкурентное поле») кнопка «Поиск» стояла вплотную
к тексту (зазор 0). Добавлен margin-left 10px на телефоне (media <600px) —
теперь есть воздух. Десктоп/планшет без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:09:18 +03:00

311 lines
10 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';
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',
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',
project_rule: 'mdi-clock-edit-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-sm-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"
>
<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">
<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; /* десктоп — clear под сайдбар 232px */
color: #e8e2d4;
}
/* Планшет — clear под rail 64px */
@media (max-width: 1279.98px) {
.app-topbar :deep(.v-toolbar__content) {
padding-left: 72px;
}
}
/* Телефон — гамбургер слева, без отступа под сайдбар */
@media (max-width: 599.98px) {
.app-topbar :deep(.v-toolbar__content) {
padding-left: 4px;
}
}
.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;
}
/* Телефон: длинный заголовок вплотную к «Поиску» — небольшой зазор слева. */
@media (max-width: 599.98px) {
.searchbar {
margin-left: 10px;
}
}
.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>