Files
portal/app/resources/js/views/errors/ErrorView.vue
T
Дмитрий 79ff60ffd9 refactor(frontend): Sprint 4 Phase B/3 — split 2 utility views (audit O-refactor-04 хвост)
ErrorView 320→178 (+ ErrorBrand 54 + ErrorIllustration 31 + ErrorActions 55 + ErrorMeta 102).
DashboardView 302→84 (+ DashboardPageHead 65 + DashboardKpiRow 97 + DashboardBalance 124).

State (config, errorCode в ErrorView; range/kpis/balance в DashboardView) остаётся
в parent ради единого route.meta-driven flow + future API-fetch'а (Phase B/1 паттерн).
DashboardPageHead использует Vue 3.5 defineModel<T>() для двусторонней привязки range.
Sub-components читают только props — без Pinia stores (mock-data flow).

Все sub-components <250 строк (acceptance threshold). Shell line counts: 178/84.

ЗАМЕЧАНИЕ по acceptance «0 components >300»: НЕ закрыто полностью. 2 файла остались
выше порога — DealsView 560 + DealDetailDrawer 386. Зафиксировано в Sprint 3 Phase C
commit 6c2f0ce: bulk-action функции (applyBulkStatus/applyBulkDelete/applyBulkExport/
undoBulkDelete/applyBulkRestoreFromTrash) и comment/reminders fetch экспонируются
через defineExpose в Vitest-тестах напрямую — дальнейшая декомпозиция требует
изменения тест-контракта (отдельным flow, не вошло в Sprint 4 Phase B).

Phase B/3 закрывает 8/12 audit-кандидатов O-refactor-04: 3 в Sprint 3 Phase C
(Top-3) + 5 в Sprint 4 Phase B/1+B/2 (admin/layout/billing/security/reminders) +
2 в B/3 (errors/dashboard). Оставшиеся: 2 крупных deals-view (defineExpose-blocked)
+ ImpersonationDialog уже <300 органически.

Регрессия: ESLint 0 + vue-tsc 0 + Vitest 416/416 + build OK 989 ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:53:09 +03:00

179 lines
6.1 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).
*
* Sprint 4 Phase B/3 — split на ErrorBrand + ErrorIllustration + ErrorActions
* + ErrorMeta (audit O-refactor-04 закрытие). State (config, errorCode) — в parent
* ради единого route.meta-driven flow.
*
* Не входит в этот коммит:
* - Реальный 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';
import ErrorBrand from '../../components/errors/ErrorBrand.vue';
import ErrorIllustration from '../../components/errors/ErrorIllustration.vue';
import ErrorActions from '../../components/errors/ErrorActions.vue';
import ErrorMeta from '../../components/errors/ErrorMeta.vue';
interface ErrorAction {
label: string;
icon: string;
to?: string;
href?: string;
onClick?: () => void;
}
interface ErrorConfig {
code: '404' | '403' | '500';
title: string;
description: string;
primaryAction: ErrorAction;
secondaryAction?: ErrorAction;
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(),
},
};
});
</script>
<template>
<v-app>
<v-main class="error-main">
<ErrorBrand />
<div class="error-content">
<ErrorIllustration :code="config.code" />
<h2 class="err-title">{{ config.title }}</h2>
<p class="err-desc">{{ config.description }}</p>
<ErrorActions :primary="config.primaryAction" :secondary="config.secondaryAction" />
<ErrorMeta
:code="config.code"
:show-status-list="config.showStatusList"
:show-request-id="config.showRequestId"
:request-id-label="config.requestIdLabel"
:request-id="config.requestId"
/>
</div>
</v-main>
</v-app>
</template>
<style scoped>
.error-main {
background: #012019;
color: #fff;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.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-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;
}
</style>