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>
This commit is contained in:
Дмитрий
2026-05-10 04:53:09 +03:00
parent 849bc73290
commit 79ff60ffd9
9 changed files with 572 additions and 404 deletions
@@ -0,0 +1,124 @@
<script setup lang="ts">
/**
* DashboardBalance — карта баланса с runway-bar (segments по дням).
* Класс `.runway-fill` сохранён — Vitest тест считает их количество.
*
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
*/
export interface Balance {
amount: string;
runwayDays: number;
runwayMax: number;
runwayLeads: number;
}
defineProps<{
balance: Balance;
}>();
</script>
<template>
<v-col cols="12" sm="6" md="3">
<v-card variant="flat" color="secondary" class="balance-card pa-4">
<div class="balance-row1">
<span class="balance-label">Баланс</span>
<v-chip size="x-small" color="primary" variant="elevated" class="ml-2">LIVE</v-chip>
</div>
<div class="balance-amount">
<span class="num">{{ balance.amount }}</span>
<span class="ru">&nbsp;</span>
</div>
<div class="runway mt-3">
<div
class="runway-bar"
:aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`"
>
<span
v-for="i in balance.runwayMax"
:key="i"
class="runway-fill"
:class="{ filled: i <= balance.runwayDays }"
/>
</div>
<div class="runway-foot text-caption">
<span
> {{ balance.runwayLeads }} лидов · хватит на
<strong>{{ balance.runwayDays }} дня</strong></span
>
<a href="/billing" class="ml-2 runway-action">пополнить </a>
</div>
</div>
</v-card>
</v-col>
</template>
<style scoped>
.balance-card {
color: #fff;
background: #012019 !important;
height: 100%;
}
.balance-row1 {
display: flex;
align-items: center;
}
.balance-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #7a8c87;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.balance-amount {
margin-top: 8px;
font-size: 32px;
font-weight: 600;
line-height: 1.1;
}
.balance-amount .num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
color: #fff;
letter-spacing: -0.01em;
}
.balance-amount .ru {
color: #7a8c87;
font-weight: 500;
}
.runway-bar {
display: flex;
gap: 4px;
}
.runway-fill {
flex: 1;
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.08);
}
.runway-fill.filled {
background: #32c8a9;
}
.runway-foot {
display: flex;
justify-content: space-between;
margin-top: 6px;
color: #7a8c87;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.runway-foot strong {
color: #fff;
font-weight: 500;
}
.runway-action {
color: #32c8a9;
text-decoration: none;
}
.runway-action:hover {
color: #fff;
}
</style>
@@ -0,0 +1,97 @@
<script setup lang="ts">
/**
* DashboardKpiRow — 3 KPI-карты (получено лидов / конверсия / активные проекты).
* Numerics через JetBrains Mono с tabular-nums.
*
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
*/
export interface Kpi {
label: string;
value: string;
unit?: string;
delta?: { dir: 'up' | 'down' | 'neutral'; text: string };
sub: string;
}
defineProps<{
kpis: Kpi[];
}>();
</script>
<template>
<v-col v-for="kpi in kpis" :key="kpi.label" cols="12" sm="6" md="3">
<v-card variant="outlined" class="kpi-card pa-4">
<div class="kpi-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
<div class="kpi-value">
{{ kpi.value }}
<span v-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
</div>
<div class="kpi-foot text-caption text-medium-emphasis mt-2">
<span v-if="kpi.delta?.dir === 'up'" class="delta-up">
<v-icon size="14" color="success">mdi-arrow-up</v-icon>
{{ kpi.delta.text }}
</span>
<span v-else-if="kpi.delta?.dir === 'down'" class="delta-down">
<v-icon size="14" color="error">mdi-arrow-down</v-icon>
{{ kpi.delta.text }}
</span>
<span v-else-if="kpi.delta" class="delta-neutral">{{ kpi.delta.text }}</span>
<span class="ml-1">{{ kpi.sub }}</span>
</div>
</v-card>
</v-col>
</template>
<style scoped>
.kpi-card {
background: #fff;
border-color: #d9d5cd !important;
transition: border-color 0.15s;
}
.kpi-card:hover {
border-color: #66635c !important;
}
.kpi-label {
font-size: 13px;
margin-bottom: 8px;
}
.kpi-value {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 32px;
font-weight: 600;
line-height: 1.1;
color: #081319;
}
.kpi-unit {
font-size: 18px;
color: #66635c;
font-weight: 500;
margin-left: 2px;
}
.kpi-foot {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.delta-up,
.delta-down,
.delta-neutral {
display: inline-flex;
align-items: center;
gap: 2px;
font-weight: 500;
}
.delta-up {
color: #2e8b57;
}
.delta-down {
color: #b83a3a;
}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
/**
* DashboardPageHead — приветствие + meta-строка (новые лиды / средняя стоимость) +
* range-toggle (Сегодня / 7 дней / 30 дней / Период…). Использует Vue 3.5
* `defineModel` для двусторонней привязки range.
*
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
*/
const range = defineModel<'today' | '7d' | '30d' | 'custom'>({ required: true });
</script>
<template>
<header class="page-head">
<div>
<h1 class="text-h4 mb-2 page-greet">Доброе утро, <em class="text-primary">Иван</em></h1>
<div class="page-meta text-body-2 text-medium-emphasis">
<span><span class="num text-primary">+3</span> новых лида с утра</span>
<span class="sep">·</span>
<span>сегодня <span class="num">11</span> · вчера <span class="num">38</span></span>
<span class="sep">·</span>
<span>средняя стоимость <span class="num">2 248&nbsp;</span></span>
</div>
</div>
<v-btn-toggle v-model="range" mandatory color="primary" density="comfortable" variant="outlined">
<v-btn value="today" size="small">Сегодня</v-btn>
<v-btn value="7d" size="small">7 дней</v-btn>
<v-btn value="30d" size="small">30 дней</v-btn>
<v-btn value="custom" size="small">Период</v-btn>
</v-btn-toggle>
</header>
</template>
<style scoped>
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.page-greet {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.page-greet em {
font-style: normal;
font-weight: 600;
}
.page-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.page-meta .sep {
color: #92907b;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
/**
* ErrorActions — две кнопки (primary + опциональная secondary) для ErrorView.
* Принимает action-объекты как props; click на handler делегирует через
* вызов action.onClick() (router-link / href обрабатывается Vuetify-биндингами).
*
* Sprint 4 Phase B/3 — split ErrorView (audit O-refactor-04 закрытие).
*/
interface ErrorAction {
label: string;
icon: string;
to?: string;
href?: string;
onClick?: () => void;
}
defineProps<{
primary: ErrorAction;
secondary?: ErrorAction;
}>();
</script>
<template>
<div class="err-actions">
<v-btn
color="primary"
variant="flat"
:prepend-icon="primary.icon"
:to="primary.to"
@click="primary.onClick?.()"
>
{{ primary.label }}
</v-btn>
<v-btn
v-if="secondary"
variant="outlined"
:prepend-icon="secondary.icon"
:to="secondary.to"
:href="secondary.href"
@click="secondary.onClick?.()"
>
{{ secondary.label }}
</v-btn>
</div>
</template>
<style scoped>
.err-actions {
display: flex;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
justify-content: center;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
/**
* ErrorBrand — фирменный блок «Лидерра.» в шапке ErrorView.
* Sprint 4 Phase B/3 — split ErrorView (audit O-refactor-04 закрытие).
*
* Класс `.top-brand` сохранён — Vitest тест selector'ом `.top-brand` рассчитывает на
* корневой элемент сабкомпонента.
*/
</script>
<template>
<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>
</template>
<style scoped>
.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;
}
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
/**
* ErrorIllustration — большая JetBrains Mono цифра кода ошибки (404 / 403 / 500)
* с акцентом teal на средней цифре. Sprint 4 Phase B/3 — split ErrorView
* (audit O-refactor-04 закрытие).
*/
defineProps<{
code: '404' | '403' | '500';
}>();
</script>
<template>
<h1 class="err-code">
{{ code[0] }}<span class="accent">{{ code[1] }}</span>{{ code[2] }}
</h1>
</template>
<style scoped>
.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;
}
</style>
@@ -0,0 +1,102 @@
<script setup lang="ts">
/**
* ErrorMeta — мета-блоки ErrorView: status-list (только для 500),
* RequestId/IncidentId с кнопкой копирования, и помощь-mailto (только 404).
*
* Sprint 4 Phase B/3 — split ErrorView (audit O-refactor-04 закрытие).
*/
defineProps<{
code: '404' | '403' | '500';
showStatusList?: boolean;
showRequestId?: boolean;
requestIdLabel?: string;
requestId?: string;
}>();
const statusList = [
{ name: 'API', status: 'ok' },
{ name: 'Telegram', status: 'degraded' },
{ name: 'YooKassa', status: 'ok' },
];
async function copyRequestId(id: string | undefined) {
if (id) {
await navigator.clipboard.writeText(id);
}
}
function statusColor(s: string): string {
return s === 'ok' ? '#2E8B57' : s === 'degraded' ? '#D9A441' : '#B83A3A';
}
</script>
<template>
<div v-if="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="showRequestId" class="err-id">
<span class="text-caption text-medium-emphasis">{{ requestIdLabel }}</span>
<span class="request-id">{{ requestId }}</span>
<v-btn
icon="mdi-content-copy"
variant="text"
size="x-small"
:aria-label="`Скопировать ID ${requestIdLabel}`"
@click="copyRequestId(requestId)"
/>
</div>
<p v-if="code === '404'" class="err-help text-caption">
Что-то не так? Напишите в
<a href="mailto:support@liderra.app" class="text-primary">support@liderra.app</a>
</p>
</template>
<style scoped>
.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>
+16 -234
View File
@@ -4,26 +4,28 @@
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html.
* MVP: page-head + 4 KPI-cards (получено лидов / конверсия / активные проекты /
* баланс). Графики (Активность по дням, Воронка из 14 статусов) и блоки ниже
* (Top deals, Recent activity) — отдельные коммиты.
* баланс). Графики (Активность по дням, Воронка из 14 статусов).
*
* Все числа сейчас mock'и — TODO: GET /api/dashboard/summary с tenant-context'ом
* по middleware SetTenantContext (фаза backend).
*
* Sprint 4 Phase B/3 — split на DashboardPageHead + DashboardKpiRow +
* DashboardBalance (audit O-refactor-04 закрытие). State (range, kpis, balance)
* остаётся в parent ради единого mock-data flow и future API-fetch'а.
*
* Примечание: «recent deals list» в Phase B/3 plan'е — на текущем дашборде нет
* (есть только charts row); если будет добавлено в будущем — выносится в
* DashboardRecentDeals.vue по аналогии.
*/
import { ref } from 'vue';
import ActivityChart from '../components/charts/ActivityChart.vue';
import FunnelChart from '../components/charts/FunnelChart.vue';
import DashboardPageHead from '../components/dashboard/DashboardPageHead.vue';
import DashboardKpiRow, { type Kpi } from '../components/dashboard/DashboardKpiRow.vue';
import DashboardBalance, { type Balance } from '../components/dashboard/DashboardBalance.vue';
const range = ref<'today' | '7d' | '30d' | 'custom'>('7d');
interface Kpi {
label: string;
value: string;
unit?: string;
delta?: { dir: 'up' | 'down' | 'neutral'; text: string };
sub: string;
}
const kpis: Kpi[] = [
{
label: 'Получено лидов',
@@ -47,7 +49,7 @@ const kpis: Kpi[] = [
},
];
const balance = {
const balance: Balance = {
amount: '14 250',
runwayDays: 4,
runwayMax: 7,
@@ -57,80 +59,11 @@ const balance = {
<template>
<v-container fluid class="dashboard pa-6">
<header class="page-head">
<div>
<h1 class="text-h4 mb-2 page-greet">Доброе утро, <em class="text-primary">Иван</em></h1>
<div class="page-meta text-body-2 text-medium-emphasis">
<span><span class="num text-primary">+3</span> новых лида с утра</span>
<span class="sep">·</span>
<span>сегодня <span class="num">11</span> · вчера <span class="num">38</span></span>
<span class="sep">·</span>
<span>средняя стоимость <span class="num">2 248&nbsp;</span></span>
</div>
</div>
<v-btn-toggle v-model="range" mandatory color="primary" density="comfortable" variant="outlined">
<v-btn value="today" size="small">Сегодня</v-btn>
<v-btn value="7d" size="small">7 дней</v-btn>
<v-btn value="30d" size="small">30 дней</v-btn>
<v-btn value="custom" size="small">Период</v-btn>
</v-btn-toggle>
</header>
<DashboardPageHead v-model="range" />
<v-row dense class="kpi-row mt-4">
<v-col v-for="kpi in kpis" :key="kpi.label" cols="12" sm="6" md="3">
<v-card variant="outlined" class="kpi-card pa-4">
<div class="kpi-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
<div class="kpi-value">
{{ kpi.value }}
<span v-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
</div>
<div class="kpi-foot text-caption text-medium-emphasis mt-2">
<span v-if="kpi.delta?.dir === 'up'" class="delta-up">
<v-icon size="14" color="success">mdi-arrow-up</v-icon>
{{ kpi.delta.text }}
</span>
<span v-else-if="kpi.delta?.dir === 'down'" class="delta-down">
<v-icon size="14" color="error">mdi-arrow-down</v-icon>
{{ kpi.delta.text }}
</span>
<span v-else-if="kpi.delta" class="delta-neutral">{{ kpi.delta.text }}</span>
<span class="ml-1">{{ kpi.sub }}</span>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="flat" color="secondary" class="balance-card pa-4">
<div class="balance-row1">
<span class="balance-label">Баланс</span>
<v-chip size="x-small" color="primary" variant="elevated" class="ml-2">LIVE</v-chip>
</div>
<div class="balance-amount">
<span class="num">{{ balance.amount }}</span>
<span class="ru">&nbsp;</span>
</div>
<div class="runway mt-3">
<div
class="runway-bar"
:aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`"
>
<span
v-for="i in balance.runwayMax"
:key="i"
class="runway-fill"
:class="{ filled: i <= balance.runwayDays }"
/>
</div>
<div class="runway-foot text-caption">
<span
> {{ balance.runwayLeads }} лидов · хватит на
<strong>{{ balance.runwayDays }} дня</strong></span
>
<a href="/billing" class="ml-2 runway-action">пополнить </a>
</div>
</div>
</v-card>
</v-col>
<DashboardKpiRow :kpis="kpis" />
<DashboardBalance :balance="balance" />
</v-row>
<v-row class="charts-row mt-4">
@@ -148,155 +81,4 @@ const balance = {
.dashboard {
max-width: 1440px;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.page-greet {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.page-greet em {
font-style: normal;
font-weight: 600;
}
.page-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.page-meta .sep {
color: #92907b;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
.kpi-card {
background: #fff;
border-color: #d9d5cd !important;
transition: border-color 0.15s;
}
.kpi-card:hover {
border-color: #66635c !important;
}
.kpi-label {
font-size: 13px;
margin-bottom: 8px;
}
.kpi-value {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 32px;
font-weight: 600;
line-height: 1.1;
color: #081319;
}
.kpi-unit {
font-size: 18px;
color: #66635c;
font-weight: 500;
margin-left: 2px;
}
.kpi-foot {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.delta-up,
.delta-down,
.delta-neutral {
display: inline-flex;
align-items: center;
gap: 2px;
font-weight: 500;
}
.delta-up {
color: #2e8b57;
}
.delta-down {
color: #b83a3a;
}
.balance-card {
color: #fff;
background: #012019 !important;
height: 100%;
}
.balance-row1 {
display: flex;
align-items: center;
}
.balance-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #7a8c87;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.balance-amount {
margin-top: 8px;
font-size: 32px;
font-weight: 600;
line-height: 1.1;
}
.balance-amount .num {
color: #fff;
letter-spacing: -0.01em;
}
.balance-amount .ru {
color: #7a8c87;
font-weight: 500;
}
.runway-bar {
display: flex;
gap: 4px;
}
.runway-fill {
flex: 1;
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.08);
}
.runway-fill.filled {
background: #32c8a9;
}
.runway-foot {
display: flex;
justify-content: space-between;
margin-top: 6px;
color: #7a8c87;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.runway-foot strong {
color: #fff;
font-weight: 500;
}
.runway-action {
color: #32c8a9;
text-decoration: none;
}
.runway-action:hover {
color: #fff;
}
</style>
+28 -170
View File
@@ -8,6 +8,10 @@
* 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).
@@ -15,13 +19,25 @@
*/
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: { label: string; icon: string; to?: string; onClick?: () => void };
secondaryAction?: { label: string; icon: string; to?: string; href?: string; onClick?: () => void };
primaryAction: ErrorAction;
secondaryAction?: ErrorAction;
showRequestId?: boolean;
requestIdLabel?: string;
requestId?: string;
@@ -97,98 +113,27 @@ const config = computed<ErrorConfig>(() => {
},
};
});
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>
<ErrorBrand />
<div class="error-content">
<h1 class="err-code">
{{ config.code[0] }}<span class="accent">{{ config.code[1] }}</span
>{{ config.code[2] }}
</h1>
<ErrorIllustration :code="config.code" />
<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>
<ErrorActions :primary="config.primaryAction" :secondary="config.secondaryAction" />
<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>
<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>
@@ -203,30 +148,6 @@ function statusColor(s: string): string {
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;
@@ -239,19 +160,6 @@ function statusColor(s: string): string {
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;
@@ -267,54 +175,4 @@ function statusColor(s: string): string {
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>