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.

Смотрите также