321 lines
10 KiB
Vue
321 lines
10 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
/**
|
||
|
|
* Универсальный экран ошибки для 404 / 403 / 500. Конфигурация через
|
||
|
|
* `route.meta.errorCode` ('404' | '403' | '500').
|
||
|
|
*
|
||
|
|
* Источник дизайна: liderra_v8_handoff/concepts/v8_errors.html.
|
||
|
|
*
|
||
|
|
* Layout: meta.layout='error' — AppShell рендерит ErrorView напрямую без
|
||
|
|
* AppLayout/AuthLayout (full-bleed теало-нуар bg + центрированный card).
|
||
|
|
*
|
||
|
|
* Не входит в этот коммит:
|
||
|
|
* - Реальный requestId/incidentId из backend (сейчас mock).
|
||
|
|
* - Status-pills из API /api/health (сейчас static на 500).
|
||
|
|
* - Sentry capture при mount 500 (для production).
|
||
|
|
*/
|
||
|
|
import { computed } from 'vue';
|
||
|
|
import { useRoute, useRouter } from 'vue-router';
|
||
|
|
|
||
|
|
interface ErrorConfig {
|
||
|
|
code: '404' | '403' | '500';
|
||
|
|
title: string;
|
||
|
|
description: string;
|
||
|
|
primaryAction: { label: string; icon: string; to?: string; onClick?: () => void };
|
||
|
|
secondaryAction?: { label: string; icon: string; to?: string; href?: string; onClick?: () => void };
|
||
|
|
showRequestId?: boolean;
|
||
|
|
requestIdLabel?: string;
|
||
|
|
requestId?: string;
|
||
|
|
showStatusList?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
const route = useRoute();
|
||
|
|
const router = useRouter();
|
||
|
|
|
||
|
|
const errorCode = computed<'404' | '403' | '500'>(
|
||
|
|
() => (route.meta.errorCode as '404' | '403' | '500' | undefined) ?? '404',
|
||
|
|
);
|
||
|
|
|
||
|
|
const config = computed<ErrorConfig>(() => {
|
||
|
|
if (errorCode.value === '403') {
|
||
|
|
return {
|
||
|
|
code: '403',
|
||
|
|
title: 'У вас нет доступа',
|
||
|
|
description:
|
||
|
|
'Эта страница принадлежит другому тенанту, либо ваша роль не позволяет её увидеть. Если вы считаете, что это ошибка — обратитесь к администратору вашей команды или в поддержку.',
|
||
|
|
primaryAction: {
|
||
|
|
label: 'На дашборд',
|
||
|
|
icon: 'mdi-view-dashboard-outline',
|
||
|
|
to: '/dashboard',
|
||
|
|
},
|
||
|
|
secondaryAction: {
|
||
|
|
label: 'Написать в поддержку',
|
||
|
|
icon: 'mdi-email-outline',
|
||
|
|
href: 'mailto:support@liderra.app',
|
||
|
|
},
|
||
|
|
showRequestId: true,
|
||
|
|
requestIdLabel: 'Запрос',
|
||
|
|
requestId: 'REQ-3F8A2-0007',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (errorCode.value === '500') {
|
||
|
|
return {
|
||
|
|
code: '500',
|
||
|
|
title: 'Что-то пошло не так',
|
||
|
|
description:
|
||
|
|
'Внутренняя ошибка — мы уже занимаемся. Команда получила уведомление. Большинство сбоев чинятся за 5–10 минут. Можно вернуться через минуту, или открыть страницу статуса.',
|
||
|
|
primaryAction: {
|
||
|
|
label: 'Попробовать снова',
|
||
|
|
icon: 'mdi-refresh',
|
||
|
|
onClick: () => location.reload(),
|
||
|
|
},
|
||
|
|
secondaryAction: {
|
||
|
|
label: 'Статус сервиса',
|
||
|
|
icon: 'mdi-pulse',
|
||
|
|
href: 'https://status.liderra.app',
|
||
|
|
},
|
||
|
|
showRequestId: true,
|
||
|
|
requestIdLabel: 'Инцидент',
|
||
|
|
requestId: 'INC-2026-0507-0034',
|
||
|
|
showStatusList: true,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
// default 404
|
||
|
|
return {
|
||
|
|
code: '404',
|
||
|
|
title: 'Страница не найдена',
|
||
|
|
description:
|
||
|
|
'Похоже, такой страницы нет — её удалили, переименовали или вы ввели адрес с опечаткой. Все рабочие экраны Лидерра доступны через дашборд.',
|
||
|
|
primaryAction: {
|
||
|
|
label: 'На дашборд',
|
||
|
|
icon: 'mdi-view-dashboard-outline',
|
||
|
|
to: '/dashboard',
|
||
|
|
},
|
||
|
|
secondaryAction: {
|
||
|
|
label: 'Назад',
|
||
|
|
icon: 'mdi-arrow-left',
|
||
|
|
onClick: () => router.back(),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
const statusList = [
|
||
|
|
{ name: 'API', status: 'ok' },
|
||
|
|
{ name: 'Telegram', status: 'degraded' },
|
||
|
|
{ name: 'YooKassa', status: 'ok' },
|
||
|
|
];
|
||
|
|
|
||
|
|
async function copyRequestId() {
|
||
|
|
if (config.value.requestId) {
|
||
|
|
await navigator.clipboard.writeText(config.value.requestId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function statusColor(s: string): string {
|
||
|
|
return s === 'ok' ? '#2E8B57' : s === 'degraded' ? '#D9A441' : '#B83A3A';
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<v-app>
|
||
|
|
<v-main class="error-main">
|
||
|
|
<a class="top-brand" href="/" aria-label="На главную">
|
||
|
|
<span class="mark" aria-hidden="true">
|
||
|
|
<svg viewBox="0 0 48 48" width="22" height="22">
|
||
|
|
<path
|
||
|
|
d="M16 14 L16 34 L32 34"
|
||
|
|
stroke="#fff"
|
||
|
|
stroke-width="4.5"
|
||
|
|
stroke-linecap="round"
|
||
|
|
stroke-linejoin="round"
|
||
|
|
fill="none"
|
||
|
|
/>
|
||
|
|
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
|
||
|
|
</svg>
|
||
|
|
</span>
|
||
|
|
<span class="brand-text">Лидерра<span class="dot">.</span></span>
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<div class="error-content">
|
||
|
|
<h1 class="err-code">
|
||
|
|
{{ config.code[0] }}<span class="accent">{{ config.code[1] }}</span
|
||
|
|
>{{ config.code[2] }}
|
||
|
|
</h1>
|
||
|
|
<h2 class="err-title">{{ config.title }}</h2>
|
||
|
|
<p class="err-desc">{{ config.description }}</p>
|
||
|
|
|
||
|
|
<div class="err-actions">
|
||
|
|
<v-btn
|
||
|
|
color="primary"
|
||
|
|
variant="flat"
|
||
|
|
:prepend-icon="config.primaryAction.icon"
|
||
|
|
:to="config.primaryAction.to"
|
||
|
|
@click="config.primaryAction.onClick?.()"
|
||
|
|
>
|
||
|
|
{{ config.primaryAction.label }}
|
||
|
|
</v-btn>
|
||
|
|
<v-btn
|
||
|
|
v-if="config.secondaryAction"
|
||
|
|
variant="outlined"
|
||
|
|
:prepend-icon="config.secondaryAction.icon"
|
||
|
|
:to="config.secondaryAction.to"
|
||
|
|
:href="config.secondaryAction.href"
|
||
|
|
@click="config.secondaryAction.onClick?.()"
|
||
|
|
>
|
||
|
|
{{ config.secondaryAction.label }}
|
||
|
|
</v-btn>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="config.showStatusList" class="status-list">
|
||
|
|
<span v-for="s in statusList" :key="s.name" class="status-item">
|
||
|
|
<span class="dot" :style="{ background: statusColor(s.status) }" />
|
||
|
|
{{ s.name }} ·
|
||
|
|
{{ s.status === 'ok' ? 'OK' : s.status === 'degraded' ? 'деградация' : 'недоступен' }}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="config.showRequestId" class="err-id">
|
||
|
|
<span class="text-caption text-medium-emphasis">{{ config.requestIdLabel }}</span>
|
||
|
|
<span class="request-id">{{ config.requestId }}</span>
|
||
|
|
<v-btn
|
||
|
|
icon="mdi-content-copy"
|
||
|
|
variant="text"
|
||
|
|
size="x-small"
|
||
|
|
:aria-label="`Скопировать ID ${config.requestIdLabel}`"
|
||
|
|
@click="copyRequestId"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<p v-if="errorCode === '404'" class="err-help text-caption">
|
||
|
|
Что-то не так? Напишите в
|
||
|
|
<a href="mailto:support@liderra.app" class="text-primary">support@liderra.app</a>
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</v-main>
|
||
|
|
</v-app>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.error-main {
|
||
|
|
background: #012019;
|
||
|
|
color: #fff;
|
||
|
|
min-height: 100vh;
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
}
|
||
|
|
|
||
|
|
.top-brand {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 10px;
|
||
|
|
color: #fff;
|
||
|
|
text-decoration: none;
|
||
|
|
padding: 24px 32px;
|
||
|
|
font-weight: 600;
|
||
|
|
font-size: 16px;
|
||
|
|
letter-spacing: -0.01em;
|
||
|
|
}
|
||
|
|
.top-brand .mark {
|
||
|
|
width: 24px;
|
||
|
|
height: 24px;
|
||
|
|
border-radius: 5px;
|
||
|
|
background: rgba(255, 255, 255, 0.06);
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
}
|
||
|
|
.top-brand .dot {
|
||
|
|
color: #32c8a9;
|
||
|
|
}
|
||
|
|
|
||
|
|
.error-content {
|
||
|
|
flex: 1;
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
padding: 24px 32px 80px;
|
||
|
|
text-align: center;
|
||
|
|
max-width: 560px;
|
||
|
|
margin: 0 auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.err-code {
|
||
|
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
|
|
font-size: 96px;
|
||
|
|
font-weight: 600;
|
||
|
|
line-height: 1;
|
||
|
|
color: #fff;
|
||
|
|
letter-spacing: -0.04em;
|
||
|
|
margin: 0 0 16px;
|
||
|
|
}
|
||
|
|
.err-code .accent {
|
||
|
|
color: #32c8a9;
|
||
|
|
}
|
||
|
|
|
||
|
|
.err-title {
|
||
|
|
font-size: 28px;
|
||
|
|
font-weight: 600;
|
||
|
|
font-variation-settings: 'opsz' 28;
|
||
|
|
letter-spacing: -0.018em;
|
||
|
|
margin: 0 0 12px;
|
||
|
|
color: #fff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.err-desc {
|
||
|
|
color: #b1c2bd;
|
||
|
|
line-height: 1.55;
|
||
|
|
font-size: 15px;
|
||
|
|
margin: 0 0 24px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.err-actions {
|
||
|
|
display: flex;
|
||
|
|
gap: 12px;
|
||
|
|
margin-bottom: 24px;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
justify-content: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-list {
|
||
|
|
display: flex;
|
||
|
|
gap: 16px;
|
||
|
|
margin-bottom: 16px;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
justify-content: center;
|
||
|
|
}
|
||
|
|
.status-item {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 6px;
|
||
|
|
font-size: 12px;
|
||
|
|
color: #b1c2bd;
|
||
|
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
|
|
}
|
||
|
|
.status-item .dot {
|
||
|
|
width: 8px;
|
||
|
|
height: 8px;
|
||
|
|
border-radius: 50%;
|
||
|
|
}
|
||
|
|
|
||
|
|
.err-id {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
background: rgba(255, 255, 255, 0.05);
|
||
|
|
padding: 6px 10px;
|
||
|
|
border-radius: 6px;
|
||
|
|
margin-bottom: 12px;
|
||
|
|
}
|
||
|
|
.request-id {
|
||
|
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
|
|
font-size: 12px;
|
||
|
|
color: #fff;
|
||
|
|
font-weight: 500;
|
||
|
|
}
|
||
|
|
|
||
|
|
.err-help {
|
||
|
|
color: #7a8c87;
|
||
|
|
margin-top: 16px;
|
||
|
|
}
|
||
|
|
</style>
|