Каркас адмінки
Панель — окремий 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/ — це групи слайсів панелі, і їхній порядок є архітектурою, а не способом розкласти файли: група може залежати від усього, що нижче за неї, і ні від чого, що вище.
| Група | Що в ній живе | |
|---|---|---|
| L0 | setup | Згенерований клієнт, обробка помилок, тема, pinia. Нічого не знає про продукт і про те, хто увійшов. |
| L1 | user | Сесія адміністратора: вхід, підтримання сесії і що робити із запитом, коли сесія збігла. |
| L2 | overview | Read-only вигляди на орендарів — команди, користувачі, агенти. |
Порядок не той, що в кабінеті. У кабінету сім груп, і п'яти з них (common, agent, chat, billing, design) у панелі відповідати нічому; скопіювати його список — означає оголосити п'ять неіснуючих груп. Це не питання охайності: перевірка зупиняється з кодом 2 на групі, про яку їй сказали, але якої немає на диску, — навмисно, бо правило, побудоване для відсутньої теки, не перевіряє нічого.
Порядок лежить у admin/cleanslice.config.cjs, і додати групу — означає дописати її туди, на своє місце. Саму перевірку не правлять ніколи: це той самий cleanslice-check.cjs, що його запускають api і кабінет, байт у байт, а make check порівнює три копії і зупиняється, якщо вони розійшлися.
Правило, на яке ви наштовхнетесь на практиці
setup не має права імпортувати з user. Спокуса очевидна: панель підписує запит токеном адміністратора — отже, транспорту логічно прочитати сесію? Ні. Тоді обв'язку не можна ні зрозуміти, ні протестувати, ні перевикористати окремо від того, що знає, хто увійшов.
Напрям розвертається. Слайс сесії сам реєструє, що робити з відмовою, а слайс помилок викликає його, так і не дізнавшись, чия була сесія:
// у слайсі, який володіє сесією — user/auth
registerApiFailureHandler((failure) => {
if (failure.response?.status !== 401) return undefined; // не наше
return renewThenReplay(failure); // наше: відповідаємо самі
});Повернути проміс означає забрати відмову собі, і відповідь, якою він розв'яжеться, отримає початковий викликач — саме так оновлена сесія повторює запит і віддає відповідь повтору замість відмови. Повернути undefined означає відмовитись, і слайс помилок покаже свій тост.
Як запустити перевірку
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.
cd admin && bun run build:api # перегенерувати; dev і build роблять це самі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, і екран малює порожній список.
Для панелі це найгірша форма помилки: «сюди не можна» невідрізненне від «на платформі порожньо». Оператор, який перевіряє, чи є такий орендар, отримує впевнену й хибну відповідь.
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/ui — components.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>. Екрани панелі — довгі таблиці орендарів, а довга таблиця хоче, щоб прокручувався сам документ. Тому блокування немає, і екрану не доводиться городити власний контейнер прокрутки заради другого екрана рядків.
Як додати слайс
Заведіть теку у своїй групі:
slices/<група>/<слайс>/, ім'я в однині.Покладіть у неї
nuxt.config.ts. Саме він робить теку шаром — більше ніде нічого перелічувати не треба:tsimport { fileURLToPath } from 'url'; import { dirname } from 'path'; const currentDir = dirname(fileURLToPath(import.meta.url)); export default defineNuxtConfig({ alias: { '#agent': currentDir }, });Аліас має бути записаний рівно в такій формі. Його вичитують із цього файлу сканером, і будь-яка інша форма відкидається голосно: мовчки пропущений аліас — це рівно та біда, заради якої все й влаштовано, коли рантайм його розв'язує, а
tscні.Всередину кладіть
pages/,stores/,components/,layouts/— що потрібно. Стори авто-імпортуються тому, що так сказано в КОРЕНЕВОМУnuxt.config.ts; власнийimports.dirsшару мовчки ігнорується — це відома пастка шарів Nuxt, і перевідкривати її не треба.Перш ніж повірити, що все гаразд, запустіть
bun run boundaries.
Дивіться також
- Шари-слайси — правило груп загалом
- Розбір слайсів — групи api
- Як ми це будуємо — конвенції обох фронтендів