Каркас админки
Панель — отдельное 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
- Как мы это строим — конвенции обоих фронтендов