79ff60ffd9
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>
179 lines
6.1 KiB
Vue
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>
|