Files
portal/app/resources/js/views/errors/ErrorView.vue
T

321 lines
10 KiB
Vue
Raw Normal View History

<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>