Files
portal/app/resources/js/router/index.ts
T
Дмитрий 793b20a39c feat(конкурентное поле): доводка фронта до прототипа — F1/F2/F3 + чистка M2
Сверка прототипа с реализацией показала расхождения — закрыты по TDD (dev, фронт):

- F1: экран «Предложения» (FieldProposalsScreen) переписан под вид «Поля» —
  карточки-плитки field-shared, тип+«предложение», крупная похожесть, Сайт +
  Справочник 2ГИС·Яндекс, править/удалять в карточке, массовый перенос; кнопка
  «Собрать конкурентов» открывает единое окно сбора 300 ₽ вместо старого autoform.
- F2: новый дружелюбный админ-экран AdminAutopodborPricingView (правка цен
  доп.услуг через PUT /api/admin/system-settings/{key} с обоснованием для аудита,
  сетка лидов для справки) + маршрут /admin/autopodbor-pricing + пункт меню.
- F3: колонка «когда списывается» в панели доп.услуг биллинга.
- M2: удалён мёртвый экран FieldManualCompetitorScreen (+ спека) — на него не
  было переходов; ручное добавление живёт окном на «Поле».

Тесты автоподбор+админ 43/43 зелёные, продакшен-вёрстка eslint-чистая, vite build .
НЕ на проде. M1 (18:00/21:00 МСК) — не баг, реальный инвариант продукта, не трогал.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:57:58 +03:00

410 lines
14 KiB
TypeScript

import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth';
/**
* Vue Router (фаза 2). История — `createWebHistory` (HTML5 history API);
* Laravel `Route::fallback` отдаёт SPA-shell для всех путей.
*
* Лейзи-импорты: каждый view подгружается отдельным chunk'ом.
*
* Auth-guard:
* - meta.requiresAuth=true → требует isAuthenticated, иначе redirect /login.
* - meta.guestOnly=true (auth-views) → если isAuthenticated, redirect /dashboard.
* - При первом заходе (cold start) — `fetchMe()` восстанавливает session-state.
*
* Dev-index badge: meta.devIndex/devLabel пробрасываются в layout-уровне
* (AppLayout/AuthLayout/AdminLayout) через `<DevIndexBadge>` для ручного фидбэка
* на localhost («элемент 16: бага X»). См. components/DevIndexBadge.vue.
*/
declare module 'vue-router' {
interface RouteMeta {
layout?: string;
title?: string;
requiresAuth?: boolean;
guestOnly?: boolean;
errorCode?: string;
devIndex?: number;
devLabel?: string;
transition?: string;
}
}
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/dashboard',
},
{
path: '/login',
name: 'login',
component: () => import('../views/auth/LoginView.vue'),
meta: { layout: 'auth', title: 'Вход', guestOnly: true, devIndex: 1, devLabel: 'Login' },
},
{
path: '/register',
name: 'register',
component: () => import('../views/auth/RegisterView.vue'),
meta: { layout: 'auth', title: 'Регистрация', guestOnly: true, devIndex: 2, devLabel: 'Register' },
},
{
path: '/confirm-email',
name: 'confirm-email',
component: () => import('../views/auth/ConfirmEmailView.vue'),
meta: { layout: 'auth', title: 'Подтверждение почты', guestOnly: true, devIndex: 6, devLabel: 'Confirm email' },
},
{
path: '/2fa',
name: '2fa',
component: () => import('../views/auth/TwoFactorView.vue'),
meta: { layout: 'auth', title: 'Двухфакторная проверка', devIndex: 3, devLabel: '2FA' },
},
{
path: '/forgot',
name: 'forgot',
component: () => import('../views/auth/ForgotPasswordView.vue'),
meta: { layout: 'auth', title: 'Сброс пароля', guestOnly: true, devIndex: 4, devLabel: 'Forgot password' },
},
{
path: '/recovery-use',
name: 'recovery-use',
component: () => import('../views/auth/UseRecoveryCodeView.vue'),
meta: { layout: 'auth', title: 'Вход по резервному коду', devIndex: 7, devLabel: 'Use recovery' },
},
{
path: '/legal/:doc(offer|privacy|refund)',
name: 'legal',
component: () => import('../views/legal/LegalDocView.vue'),
meta: { layout: 'public', title: 'Правовые документы' },
},
{
path: '/pricing',
name: 'pricing',
component: () => import('../views/PricingView.vue'),
meta: { layout: 'public', title: 'Цены' },
},
{
path: '/reset/:token',
name: 'reset-password',
component: () => import('../views/auth/ResetPasswordView.vue'),
meta: { layout: 'auth', title: 'Новый пароль', guestOnly: true, devIndex: 5, devLabel: 'Reset password' },
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('../views/DashboardView.vue'),
meta: {
layout: 'app',
title: 'Дашборд',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 8,
devLabel: 'Dashboard',
},
},
{
path: '/deals',
name: 'deals',
component: () => import('../views/DealsView.vue'),
meta: {
layout: 'app',
title: 'Сделки',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 9,
devLabel: 'Сделки',
},
},
{
path: '/kanban',
name: 'kanban',
component: () => import('../views/KanbanView.vue'),
meta: {
layout: 'app',
title: 'Канбан',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 10,
devLabel: 'Канбан',
},
},
{
path: '/projects',
name: 'projects',
component: () => import('../views/ProjectsView.vue'),
meta: {
layout: 'app',
title: 'Проекты',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 16,
devLabel: 'Проекты',
},
},
{
path: '/autopodbor',
name: 'autopodbor',
component: () => import('../views/autopodbor/AutopodborView.vue'),
meta: { layout: 'app', title: 'Конкурентное поле', requiresAuth: true, transition: 'ld-route-fadeup', devLabel: 'Конкурентное поле' },
},
{
path: '/billing',
name: 'billing',
component: () => import('../views/BillingView.vue'),
meta: {
layout: 'app',
title: 'Биллинг и тарифы',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 13,
devLabel: 'Биллинг',
},
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
meta: {
layout: 'app',
title: 'Настройки',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 14,
devLabel: 'Настройки',
},
},
{
path: '/reports',
name: 'reports',
component: () => import('../views/ReportsView.vue'),
meta: {
layout: 'app',
title: 'Отчёты',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 12,
devLabel: 'Отчёты',
},
},
{
path: '/import',
name: 'import',
component: () => import('../views/ImportView.vue'),
meta: {
layout: 'app',
title: 'Импорт данных',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 29,
devLabel: 'Импорт данных',
},
},
// Админка SaaS — отдельный layout с под-брендом ADMIN.
// TODO: дополнительный role-guard на super_admin.
{
path: '/admin',
redirect: '/admin/dashboard',
},
{
path: '/admin/dashboard',
name: 'admin-dashboard',
component: () => import('../views/admin/AdminDashboardView.vue'),
meta: { layout: 'admin', title: 'Командный центр', requiresAuth: true, devIndex: 20, devLabel: 'Admin Dashboard' },
},
{
path: '/admin/tenants',
name: 'admin-tenants',
component: () => import('../views/admin/AdminTenantsView.vue'),
meta: { layout: 'admin', title: 'Тенанты', requiresAuth: true, devIndex: 21, devLabel: 'Admin Tenants' },
},
{
path: '/admin/tenants/:code',
name: 'admin-tenant-detail',
component: () => import('../views/admin/AdminTenantDetailView.vue'),
meta: { layout: 'admin', title: 'Тенант', requiresAuth: true, devIndex: 22, devLabel: 'Admin Tenant Detail' },
},
{
path: '/admin/billing',
name: 'admin-billing',
component: () => import('../views/admin/AdminBillingView.vue'),
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' },
},
{
path: '/admin/leads',
name: 'admin-leads',
component: () => import('../views/admin/AdminLeadsView.vue'),
meta: { layout: 'admin', title: 'Лиды', requiresAuth: true, devLabel: 'Admin Leads' },
},
{
path: '/admin/leads/:id',
name: 'admin-lead-detail',
component: () => import('../views/admin/AdminLeadDetailView.vue'),
meta: { layout: 'admin', title: 'Лид', requiresAuth: true, devLabel: 'Admin Lead Detail' },
},
{
path: '/admin/incidents',
name: 'admin-incidents',
component: () => import('../views/admin/AdminIncidentsView.vue'),
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true, devIndex: 24, devLabel: 'Admin Incidents' },
},
{
path: '/admin/incidents/:id',
name: 'admin-incident-detail',
component: () => import('../views/admin/AdminIncidentDetailView.vue'),
meta: { layout: 'admin', title: 'Инцидент', requiresAuth: true },
},
{
path: '/admin/system',
name: 'admin-system',
component: () => import('../views/admin/AdminSystemView.vue'),
meta: { layout: 'admin', title: 'Система', requiresAuth: true, devIndex: 25, devLabel: 'Admin System' },
},
{
path: '/admin/pricing-tiers',
name: 'admin-pricing-tiers',
component: () => import('../views/admin/AdminPricingTiersView.vue'),
meta: {
layout: 'admin',
title: 'Тарифная сетка',
requiresAuth: true,
devIndex: 27,
devLabel: 'Admin Pricing Tiers',
},
},
{
path: '/admin/autopodbor-pricing',
name: 'admin-autopodbor-pricing',
component: () => import('../views/admin/AdminAutopodborPricingView.vue'),
meta: {
layout: 'admin',
title: 'Тарифы «Конкурентного поля»',
requiresAuth: true,
devIndex: 28,
devLabel: 'Admin Autopodbor Pricing',
},
},
{
path: '/admin/supplier-prices',
name: 'admin-supplier-prices',
component: () => import('../views/admin/AdminSupplierPricesView.vue'),
meta: {
layout: 'admin',
title: 'Цены поставщиков',
requiresAuth: true,
devIndex: 28,
devLabel: 'Admin Supplier Prices',
},
},
{
path: '/admin/impersonation',
name: 'admin-impersonation',
component: () => import('../views/admin/AdminImpersonationView.vue'),
meta: {
layout: 'admin',
title: 'Impersonation',
requiresAuth: true,
devIndex: 26,
devLabel: 'Admin Impersonation',
},
},
{
path: '/admin/supplier-integration',
name: 'admin-supplier-integration',
component: () => import('../views/admin/AdminSupplierIntegrationView.vue'),
meta: {
layout: 'admin',
title: 'Интеграция с поставщиком',
requiresAuth: true,
devIndex: 30,
devLabel: 'Admin Supplier Integration',
},
},
{
path: '/admin/supplier-projects',
name: 'admin-supplier-projects',
component: () => import('../views/admin/AdminSupplierProjectsView.vue'),
meta: {
layout: 'admin',
title: 'Проекты у поставщика',
requiresAuth: true,
devIndex: 31,
devLabel: 'Admin Supplier Projects',
},
},
{
path: '/admin/pd-subject-requests',
name: 'admin-pd-subject-requests',
component: () => import('../views/admin/AdminPdSubjectRequestsView.vue'),
meta: {
layout: 'admin',
title: 'Обращения ПДн',
requiresAuth: true,
devIndex: 32,
devLabel: 'Admin PD Requests',
},
},
{
path: '/help',
name: 'help',
component: () => import('../views/HelpView.vue'),
meta: {
layout: 'app',
title: 'Помощь',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 33,
devLabel: 'Помощь',
},
},
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
{
path: '/403',
name: 'forbidden',
component: () => import('../views/errors/ErrorView.vue'),
meta: { layout: 'error', errorCode: '403', title: 'Доступ запрещён', devIndex: 15, devLabel: 'Ошибка 403' },
},
{
path: '/500',
name: 'server-error',
component: () => import('../views/errors/ErrorView.vue'),
meta: { layout: 'error', errorCode: '500', title: 'Ошибка сервера', devIndex: 15, devLabel: 'Ошибка 500' },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('../views/errors/ErrorView.vue'),
meta: { layout: 'error', errorCode: '404', title: 'Страница не найдена', devIndex: 15, devLabel: 'Ошибка 404' },
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
let authInitialized = false;
router.beforeEach(async (to) => {
const auth = useAuthStore();
// На первый переход — восстанавливаем session через /api/auth/me.
// Если cookie валиден — auth.user будет заполнен; иначе остаётся null.
// §6: на гостевых страницах (login/register/...) при холодном старте /me не
// дёргаем — у неавторизованного посетителя это давало 401-шум в консоли.
// authInitialized НЕ ставим — при переходе на защищённый роут сессия
// восстановится тогда (cold start защищённой страницы всё ещё зовёт /me).
if (!authInitialized && !to.meta.guestOnly) {
await auth.fetchMe();
authInitialized = true;
}
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { path: '/login', query: { redirect: to.fullPath } };
}
if (to.meta.guestOnly && auth.isAuthenticated) {
return { path: '/dashboard' };
}
return true;
});