Newer
Older
gn-synapse / frontend / src / App.vue
<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>