Skip to content

Каркас адмінки

Панель — окремий Nuxt-застосунок поруч із кабінетом, і зібрана вона так само: шари-слайси, зареєстровані як шари Nuxt. Ця сторінка про раму, в якій стоять екрани: як реєструється слайс, які є групи і в якому порядку, звідки береться клієнт до api і що стається з невдалим викликом.

Про жоден екран тут нічого немає. У входу та у списків орендарів будуть свої сторінки, коли вони приїдуть (AGNT2-159, AGNT2-160).

Що лежить у застосунку

admin/
├── nuxt.config.ts             реєструє шари, оголошує каталоги сторів
├── registerSlices.ts          знаходить шари — слайс це тека, списків ніде немає
├── cleanslice.config.cjs      порядок груп, і єдине місце, де він записаний
├── openapi-ts.config.ts       як генерується клієнт до api
├── scripts/cleanslice-check.cjs   перевірка меж — байт у байт та сама, що в api
└── slices/
    ├── setup/                 обв'язка, потрібна кожному екрану і не про екрани
    │   ├── api/               згенерований клієнт та його підключення
    │   ├── error/             що робить невдалий виклик
    │   ├── pinia/             сховища
    │   └── theme/             tailwind, палітра бренду, чотири примітиви, оболонка
    ├── user/                  хто працює в панелі — сесія
    └── overview/              read-only вигляди на орендарів

Групи, і чому порядок — це правило

Теки першого рівня всередині slices/ — це групи слайсів панелі, і їхній порядок є архітектурою, а не способом розкласти файли: група може залежати від усього, що нижче за неї, і ні від чого, що вище.

ГрупаЩо в ній живе
L0setupЗгенерований клієнт, обробка помилок, тема, pinia. Нічого не знає про продукт і про те, хто увійшов.
L1userСесія адміністратора: вхід, підтримання сесії і що робити із запитом, коли сесія збігла.
L2overviewRead-only вигляди на орендарів — команди, користувачі, агенти.

Порядок не той, що в кабінеті. У кабінету сім груп, і п'яти з них (common, agent, chat, billing, design) у панелі відповідати нічому; скопіювати його список — означає оголосити п'ять неіснуючих груп. Це не питання охайності: перевірка зупиняється з кодом 2 на групі, про яку їй сказали, але якої немає на диску, — навмисно, бо правило, побудоване для відсутньої теки, не перевіряє нічого.

Порядок лежить у admin/cleanslice.config.cjs, і додати групу — означає дописати її туди, на своє місце. Саму перевірку не правлять ніколи: це той самий cleanslice-check.cjs, що його запускають api і кабінет, байт у байт, а make check порівнює три копії і зупиняється, якщо вони розійшлися.

Правило, на яке ви наштовхнетесь на практиці

setup не має права імпортувати з user. Спокуса очевидна: панель підписує запит токеном адміністратора — отже, транспорту логічно прочитати сесію? Ні. Тоді обв'язку не можна ні зрозуміти, ні протестувати, ні перевикористати окремо від того, що знає, хто увійшов.

Напрям розвертається. Слайс сесії сам реєструє, що робити з відмовою, а слайс помилок викликає його, так і не дізнавшись, чия була сесія:

ts
// у слайсі, який володіє сесією — user/auth
registerApiFailureHandler((failure) => {
  if (failure.response?.status !== 401) return undefined; // не наше
  return renewThenReplay(failure);                        // наше: відповідаємо самі
});

Повернути проміс означає забрати відмову собі, і відповідь, якою він розв'яжеться, отримає початковий викликач — саме так оновлена сесія повторює запит і віддає відповідь повтору замість відмови. Повернути undefined означає відмовитись, і слайс помилок покаже свій тост.

Як запустити перевірку

bash
cd admin && bun run boundaries     # nuxt prepare, потім перевірка
cleanslice-check: OK — 3 group(s) [setup -> user -> overview], 42 modules, 353 ms

Вона ж виконується перед bun run dev, тому порушення зупиняє запуск, а не їде далі, і входить до make check. Порушення виглядає так:

cleanslice-check: FAILED — 3 group(s) [setup -> user -> overview], 44 modules
  error no-upward-import-from-setup: slices/setup/error/utils/handleError.ts
      -> slices/user/auth/utils/probe.ts
    'setup' (L0) may not import higher groups: user, overview

Двох речей перевірка не бачить, і обидві тут важливі. Авто-імпортовані composables і компоненти не лишають рядка import, тож авто-імпорт через межу групи для неї невидимий. І імпорт, який нікуди не розв'язується, взагалі не дає ребра — заради цього існує tsconfig.boundaries.json: він перекорінює згенеровані аліаси Nuxt так, щоб #api і #error розв'язувалися в реальні файли. Без нього перевірка проходила б на будь-якому коді взагалі. Повний перелік сліпих плям — у стандарті CleanSlice.

Як дотягнутися до api

Спосіб один, і він генерований. openapi-ts читає api/swagger-spec.json — артефакт, який пише make swagger — і створює SDK у slices/setup/api/data/repositories/api. Його ніхто не править; клієнт, написаний руками, був би другим описом кожного ендпоінта, і ці двоє розійшлися б того ж дня, коли переїде перший DTO.

bash
cd admin && bun run build:api      # перегенерувати; dev і build роблять це самі
ts
import { Teams, unwrap } from '#api';

const { data } = await Teams.listTeams();
const teams = unwrap(data);        // api загортає все у { success, data }

Три деталі, у яких легко помилитися, і тому кожна закріплена в одному місці:

  • Адреса api береться з runtimeConfig.public.apiBase, який живиться NUXT_PUBLIC_API_BASE, і ставиться рівно одним рядком у плагіні setup/api. Власний конфіг генератора не має задавати baseUrl: він виконується на етапі обчислення модуля, до всіх плагінів, тож що б він не записав — це значення, яке плагіну доведеться перезаписувати, і будь-яка помилка в порядку веде панель на чужий хост.
  • Кожен запит лишає позаду відправлювану копію себе, зняту до відправки. fetch з'їдає тіло запиту, а з'їдене тіло вдруге не піде — тож без такої копії на 401 не було б чим відповісти.
  • Повторюваному запиту переписують заголовки на поточні, бо власні заголовки запиту сильніші за конфігурацію клієнта. Пропустіть це — і повтор понесе на провід мертвий токен, а це не відновлення, а петля оновлень.

Просіть throwOnError, інакше відмова приїде як порожнеча

Згенерований клієнт на 4xx не кидає. Він спокійно повертається: error заповнений, data лишилась undefined — тому try/catch навколо виклику не спрацьовує, unwrap дістає undefined, і екран малює порожній список.

Для панелі це найгірша форма помилки: «сюди не можна» невідрізненне від «на платформі порожньо». Оператор, який перевіряє, чи є такий орендар, отримує впевнену й хибну відповідь.

ts
const { data } = await Teams.listTeams({ throwOnError: true });

Шлях відмови відпрацьовує в будь-якому разі — перехоплювач забирає її, тост з'являється. throwOnError змінює лише те, чи дізнається про відмову САМ ВИКЛИКАЧ, а сховище списку, яке не дізналося, не може відрізнити «порожньо» від «заборонено».

Уся біда саме в цій асиметрії, бо в них різний час життя. Тост гасить себе через п'ять секунд (setTimeout у сховищі помилок), а порожня таблиця лишається на екрані стільки, скільки на неї дивляться, і своїми словами стверджує, що команд на платформі немає. Тобто відмова не тиха — вона недовго гучна, а потім упевнено хибна, і це гірше: сигнал спливає, а хибне твердження ні. Руками це легко не помітити: перший знімок для звіту AGNT2-158 упіймав порожній кадр саме з цієї причини. Закріплюйте це перевіркою на саму опцію, а не на відрендерений результат: у slices/overview/team/stores/team.spec.ts (AGNT2-160) перевіряється, що виклик її несе, тому контроль робиться зняттям одного рядка.

Коли виклик не вдався

Шлях у відмови один і короткий. Перехоплювачі віддають відмову слайсу помилок; той пропонує її всім, хто зареєстрував обробник; якщо ніхто не забрав — конверт api { success: false, code, message } стає тостом.

Провайдер тостів змонтований один раз, в оболонці панелі (setup/theme/layouts/default.vue). Екрану, який хоче, щоб помилка була видима, не треба робити нічого.

Тема

Панель використовує палітру і бренд-пресет кабінету — оператору, який ходить між ними, не має доводитись наново вчити, як виглядає «це працює», — і чотири примітиви shadcn-vue: Button, Card, Input, Label. Вони авто-імпортуються, без префікса.

Решти компонентів кабінету тут навмисно немає: вони приїхали б мертвими. Потрібний екрану ставте CLI-єм shadcn-vue у slices/setup/theme/components/uicomponents.json уже налаштований на ці шляхи.

І обов'язково прочитайте після цього діф package.json. CLI дописує туди власні залежності, і не завжди ті, якими компонент користується: встановлення table запропонувало @lucide/vue, який панелі не потрібен зовсім, і підняло @vueuse/core з ^14.3.0 до ^14.4.0 заради імпорту, який і так розв'язувався. Помилкою це не виглядає — компонент працює в будь-якому разі, — тож зайве треба повернути й перевстановити. Мовчки зсунутий caret-діапазон у цьому репозиторії вже ламав збірку (знайшла це AGNT2-160).

Що не скопійовано: кабінет прибиває html/body до h-full overflow-hidden, бо він — фіксована оболонка, прокрутка всередині <main>. Екрани панелі — довгі таблиці орендарів, а довга таблиця хоче, щоб прокручувався сам документ. Тому блокування немає, і екрану не доводиться городити власний контейнер прокрутки заради другого екрана рядків.

Як додати слайс

  1. Заведіть теку у своїй групі: slices/<група>/<слайс>/, ім'я в однині.

  2. Покладіть у неї nuxt.config.ts. Саме він робить теку шаром — більше ніде нічого перелічувати не треба:

    ts
    import { fileURLToPath } from 'url';
    import { dirname } from 'path';
    
    const currentDir = dirname(fileURLToPath(import.meta.url));
    
    export default defineNuxtConfig({
      alias: { '#agent': currentDir },
    });

    Аліас має бути записаний рівно в такій формі. Його вичитують із цього файлу сканером, і будь-яка інша форма відкидається голосно: мовчки пропущений аліас — це рівно та біда, заради якої все й влаштовано, коли рантайм його розв'язує, а tsc ні.

  3. Всередину кладіть pages/, stores/, components/, layouts/ — що потрібно. Стори авто-імпортуються тому, що так сказано в КОРЕНЕВОМУ nuxt.config.ts; власний imports.dirs шару мовчки ігнорується — це відома пастка шарів Nuxt, і перевідкривати її не треба.

  4. Перш ніж повірити, що все гаразд, запустіть bun run boundaries.

Дивіться також