<script setup>
// Каркас приложения — GnNavigationShell из gnexus-ui-kit: topbar + drawer + footer.
// Страницы входа/отсечения идут без каркаса (meta.bare в роутере).
//
// Футер дровер — карточка профиля (GnUserCard) с кнопкой выхода; здесь же живёт
// сессия: GET /api/v1/me каждые 45 c (single sign-out: вебхук отзывает токены,
// heartbeat ловит 401 и уводит на /login). Роль из того же ответа решает,
// какая навигация — админская или личный раздел.
//
// :key="route.path" — обход ограничения vue-адаптера кита: GnNavList читает
// route.path ($route) один раз при первом рендере, а vue-router отдаёт $route
// как unref(currentRoute) — без реактивности, computed с isActive закешируется
// навсегда. Перезмонтирование шела на каждую смену пути даёт корректную
// подсветку активного пункта навигации.
import { computed, onUnmounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { GnIdentity, GnIconButton, GnNavigationShell, GnToastProvider } from "gnexus-ui-kit/vue";
import { clearToken, logout, me } from "./api.js";
const route = useRoute();
const router = useRouter();
// Навигация по роли. Администратор — вся админка; обычный пользователь —
// только личный раздел (роль из GET /api/v1/me, гейт дублирует require_admin).
// Иконки — только Phosphor (правила кита); pulse/shapes в шрифте кита отсутствуют.
const adminItems = [
{ to: "/dashboard", label: "Обзор", icon: "ph-activity" },
{ to: "/sources", label: "Источники", icon: "ph-broadcast" },
{ to: "/types", label: "Типы", icon: "ph-squares-four" },
{ to: "/targets", label: "Цели", icon: "ph-target" },
{ to: "/rules", label: "Правила", icon: "ph-funnel" },
{ to: "/deliveries", label: "Доставки", icon: "ph-paper-plane-tilt" },
{ to: "/settings", label: "Настройки", icon: "ph-gear-six" },
];
const adminCurrents = {
"/dashboard": "Обзор",
"/sources": "Источники",
"/types": "Типы",
"/targets": "Цели",
"/rules": "Правила",
"/deliveries": "Доставки",
"/settings": "Настройки",
};
const items = computed(() =>
profile.value?.system_role === "user"
? [
{ to: "/my-events", label: "Мои события", icon: "ph-user" },
{ to: "/settings", label: "Настройки", icon: "ph-gear-six" },
]
: adminItems,
);
// Префиксно: /sources/7 тоже подписывается «Источники».
const current = computed(() => {
const paths =
profile.value?.system_role === "user" ? { "/my-events": "Мои события" } : adminCurrents;
return (
Object.entries(paths)
.filter(([p]) => route.path === p || route.path.startsWith(p + "/"))
.sort((a, b) => b[0].length - a[0].length)
.map(([, v]) => v)[0] || ""
);
});
const bare = computed(() => Boolean(route.meta.bare));
// Профиль/роль (не сессия): heartbeat ниже проверяет токен каждые 45 c.
const profile = ref(null);
let heartbeatTimer = null;
// Имя для карточки: display_name -> username -> email (userinfo profile scope).
const displayName = computed(
() => profile.value?.display_name || profile.value?.username || profile.value?.email || "",
);
const initials = computed(() => {
const src = displayName.value || profile.value?.email || "";
return src
.split(/[.\s@_]+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("");
});
// Аватар identity: картинка из gnexus-auth или initials (GnAvatar рендерит
// их строкой сам). size sm; статус-точка не ставим — «online» лишь сбивает
// с толку (пользователь, глядящий на страницу, и так в сети).
const identityAvatar = computed(() => ({
size: "sm",
...(profile.value?.avatar_url
? { src: profile.value.avatar_url, alt: displayName.value }
: { initials: initials.value }),
}));
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function authFailure(ex) {
stopHeartbeat();
// 403 теперь почти недостижим (/me без ролевого гейта) — страховка.
if (ex?.status === 403) router.push("/denied");
else if (ex?.status === 401) {
clearToken();
router.push("/login");
}
}
async function loadProfile() {
try {
profile.value = await me();
} catch (ex) {
profile.value = null;
authFailure(ex);
}
}
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(async () => {
try {
profile.value = await me();
} catch (ex) {
authFailure(ex);
}
}, 45000);
}
watch(
bare,
(isBare) => {
if (isBare) {
profile.value = null;
stopHeartbeat();
} else {
loadProfile();
startHeartbeat();
}
},
{ immediate: true },
);
onUnmounted(stopHeartbeat);
async function signOut() {
stopHeartbeat();
profile.value = null;
await logout();
router.push("/login");
}
</script>
<template>
<GnNavigationShell
v-if="!bare"
:key="route.path"
:items="items"
brand="Synapse"
logo-src="/assets/imgs/synapse-mark.svg"
title="Synapse"
subtitle="Хаб уведомлений Gnexus"
:current="current"
>
<template #footer>
<!-- Паттерн «Navigation Shell Profile Footer» из доков кита:
ссылка-identity + кнопки-иконки. -->
<a
v-if="profile"
class="profile-identity"
:href="profile.account_url"
target="_blank"
rel="noopener noreferrer"
>
<GnIdentity
:title="displayName"
:meta="profile.email || ''"
:avatar="identityAvatar"
/>
</a>
<GnIdentity
v-else-if="!bare"
title="Загрузка…"
:avatar="{ size: 'sm' }"
/>
<GnIconButton icon="ph-sign-out" label="Выйти" @click="signOut" />
</template>
<template #content>
<!-- Тост-провайдер у корня каркаса: useToast() в разделах админки. -->
<GnToastProvider>
<RouterView />
</GnToastProvider>
</template>
</GnNavigationShell>
<RouterView v-else />
</template>
<style>
/* Тултипы заголовков таблиц: панель кита по умолчанию уезжает НАД подписью —
и обрезается overflow:hidden/auto у .table-wrapper. Раскрываем панель ВНИЗ,
поверх тела таблицы: она остаётся внутри границ обёртки и видна. */
.th-tip .tooltip-panel {
top: calc(100% + 10px);
bottom: auto;
}
/* Спиннер загрузки (GnLoader circle) — на всю строку, приглушённо:
дефолтный лоадер кита прилипает к верхнему краю и выглядит «висящим». */
.circle-loader {
padding: var(--gn-space-6) 0;
color: var(--gn-color-text-medium);
font-size: 0.9em;
}
</style>