@ecosystem/ui-core (0.59.0)
Installation
@ecosystem:registry=npm install @ecosystem/ui-core@0.59.0"@ecosystem/ui-core": "0.59.0"About this package
@ecosystem/ui-core
Общая Vue-библиотека generic entity-UI платформы Ecosystem: готовые страницы «список» и «карточка» поверх entity-API бэкенда (Ecosystem.Core.Web.Entities: /api/entities, /api/viewconfig/{entity}/*, /api/listview/{entity}, /api/crud/{entity}). Выросла из демо-SPA Resources/ (удалено, история — в git).
Форма поставки: пакет-источник
Пакет не собирается сам — exports указывает на src/index.ts, компиляцию .ts/.vue выполняет Vite-сборка приложения-потребителя (штатный подход для внутренних библиотек). Проверка типов пакета: npm run type-check (vue-tsc).
Подключение — обычная установка с полки Forgejo (npm install @ecosystem/ui-core, см. docs/PACKAGES.md в корне репозитория); для локальной разработки потребителя рядом с ядром подойдёт file:-установка:
// package.json приложения
"dependencies": {
"@ecosystem/ui-core": "file:../../Ecosystem-Core/ui"
}
Настройка HTTP (обязательно, один раз при старте приложения)
Библиотека не знает, как приложение аутентифицируется, — настройте клиент:
import { configureEntityHttp } from '@ecosystem/ui-core'
// Вариант А: bearer-хост (SPA с OIDC-токенами)
configureEntityHttp({
baseUrl: config.apiBaseUrl, // '' = same-origin
getAccessToken: () => auth.getAccessToken(),
onUnauthorized: () => auth.signIn(location.pathname + location.search)
})
// Вариант Б: куки-хост (SPA IdentityServer)
configureEntityHttp({
credentials: 'include', // кука сессии
onUnauthorized: () => router.push({ name: 'login' })
// xhrHeader включён по умолчанию: X-Requested-With на мутациях — анти-CSRF куки-хостов
})
Живые обновления (SignalR)
Списки и карточки подписываются на живые события сущностей сами — отдельной настройки в SPA нет. Соединение (одно на вкладку) поднимается лениво первой страницей и переиспользует настройки configureEntityHttp (baseUrl, кука/bearer, XHR-заголовок). Что происходит по событию:
- список — тихая перезагрузка текущего окна выборки (без спиннера; лента перечитывает всё догруженное, страница — свою страницу) с дебаунсом 400 мс;
- карточка — чужое изменение записи (сверка версии
lastUpdateAt) тихо перечитывается: свежая версия строки снимает ложный 409, чужие значения подтягиваются в поля, которые пользователь не трогал (тронутые важнее фона); удалённая в другом месте запись показывает баннер; - своё сохранение события не дёргают: его версия уже на клиенте.
Требования к хосту:
- бэкенд публикует хаб:
app.MapEntityEvents()(ядро ≥ 0.34.0); без него клиент получает 404 на рукопожатии и молча выключает механизм до перезагрузки страницы — SPA совместимо с любым бэкендом; - прокси перед хостом должен пропускать WebSocket на
/api/entities/events(nginx:proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";для этого location) — иначе клиент сам откатится на long-polling (работает, но с задержкой опроса); - bearer-хосты живут на long-polling всегда: у WebSocket-рукопожатия нет заголовка
Authorization, и клиент SignalR откатывается на транспорт, где заголовок есть (токен-в-URL сознательно не используется — течёт в логи прокси).
Обрывы соединение переживает само (бесконечный реконнект с нарастающей паузой, подписки восстанавливаются). Доменным блокам механизм доступен напрямую:
import { subscribeEntityEvents } from '@ecosystem/ui-core'
const stop = subscribeEntityEvents('outboxemail', event => {
// event: { entity, kind: 'created'|'updated'|'deleted', id, version, userId }
})
// при размонтировании обязательно: stop()
Событие — только «что изменилось» (данных записи нет): перечитывайте своим запросом под своими правами. Сервер шлёт события подписчику лишь при праве <entity>.read (без права подписка тихо игнорируется). Рассылаются generic-мутации и загрузка файлов; доменные записи мимо generic-CRUD (консьюмеры шины, фоновые задачи) события шлют сами через IEntityChangeNotifier бэкенда — иначе их изменения на открытых экранах не «оживут».
Маршруты-контракт
Компоненты навигируют по именам маршрутов listview и detailview — приложение обязано объявить их со следующими props:
import { ListViewPage, DetailViewPage } from '@ecosystem/ui-core'
const routes = [
{ path: '/listview/:entity', name: 'listview', component: ListViewPage, props: true },
{ path: '/detailview/:entity/:id', name: 'detailview', component: DetailViewPage, props: true }
]
Открытие строки (и «Создать») в ListViewPage показывает модальную карточку (DetailViewModal) поверх списка; кнопка «развернуть» в её шапке ведёт на отдельную страницу detailview — поэтому маршрут обязателен по-прежнему (плюс он же — deep-link на запись). Страница и модалка — хосты одной формы DetailViewForm (экспортируется отдельно: пропсы entity/id/variant плюс preview-editors/preview-layout для предпросмотра черновика раскладки, события config-loaded/created/saved/deleted) — при необходимости встраивайте её в свои сценарии. Шестерёнку настройки раскладки рисуют именно хосты (у них шапка) — см. «Раскладка карточки».
Список доступных пользователю сущностей для навигации — entitiesApi.getEntities().
Список (ListViewPage)
Пропсы: entity (обязательный), actions?: ListAction[] (действия этого экземпляра; совпадение ключа замещает глобальные), hiddenActions?: string[] (скрыть действия по ключам), lockedFilters?: FilterEntry[] (зашитые условия выборки: добавляются к пользовательским фильтрам по «И» и чипами не показываются), embedded?: boolean (встраиваемый режим — без заголовка страницы, центрирования и настройки колонок; так виджет «дети по ссылке» встраивает список в карточку).
Тулбар действий. Встроенные действия (ключ/порядок): create/10 (при permission.write), edit/20 (активно ровно при одной выбранной строке), delete/30 (активно при выборе ≥ 1; мягкое удаление по одной записи, ошибки — одним баннером), refresh/90. Колонка чекбоксов появляется автоматически, когда видимо хотя бы одно действие с requiresSelection; клик по строке по-прежнему открывает карточку, клик по чекбоксу — нет.
Колоночные фильтры — единственный способ сузить выборку: строки сквозного поиска над таблицей нет (по дизайну). По одному условию на колонку, сервер объединяет их по «И». Операторы по типу компонента:
| Компонент | Операторы |
|---|---|
| String | содержит (по умолчанию), начинается с, равно, не равно |
String с params.kind='guid' |
равно, не равно (значение — валидный guid) |
| Int / Number / Time | =, ≠, >, ≥, <, ≤ |
| DateTime | в этот день, после дня, начиная с дня, до дня, по день («равно» раскладывается в диапазон суток; «не равно» нет — нужен «ИЛИ») |
| Date | =, ≠, после, начиная с, до, по |
| Boolean | один селект (Да/Нет) |
| Enum | равно, не равно (значение — селект по значениям enum) |
| Array | содержит, пусто, не пусто (значение — один элемент; инпут по виду элемента) |
| Reference | равно, не равно (значение — мини-пикер: поиск по Title цели) |
«Пусто/не пусто» (eq/neq null) добавляются только для колонок с nullable: true — поле появилось в Core-Api 0.4.0 (исключение — коллекции: там «пусто» это ещё и пустой массив, поэтому оператор есть всегда); со старым бэкендом этих операторов просто нет, а guid-колонки ведут себя как строки (сервер ответит 400 на «содержит»). Регистронезависимые «содержит»/«начинается с» — тоже с Core-Api 0.4.0. Текст и числа применяются с дебаунсом 300 мс, остальное — сразу; «Сбросить фильтры» — в тулбаре справа.
Enum-поля
Система enum (по образцу Визари): значения свойства-enum сервер кладёт прямо в конфиг представления — viewComponent: { name: 'Enum', params: { values: [{ value, name, title, icon?, color? }] } } (тип EnumValue, хелпер enumValues(params)); отдельного запроса-справочника нет, в данных/патчах/фильтрах enum ходит числом value. Заголовок/иконка/цвет значений задаются на бэкенде и только в коде: атрибут [EnumDisplay(Title, Icon, Color)] на членах enum (заголовок — ещё и [Description]) либо fluent entities.Enum<T>(e => e.Display(...)) при регистрации. icon/color — с Core-Api 0.9.0.
- Ячейка списка (
CellEnum) — бейдж:colorкрасит мягкий фон и текст,icon(короткий текст/эмодзи) рендерится перед заголовком; без цвета — нейтральный серый бейдж. - Редактор карточки (
EditorEnum) — по умолчанию до 4 значений горизонтальная радио-группа, дальше селект; вид задаётся принудительно на свойстве (params.viewType: 'radio' | 'select'— на бэкенде атрибут[EnumView(EnumViewKind.Select)]либо fluent.EnumView(...)). У необязательного поля есть пункт «—» (null), обязательное требует явный выбор (non-nullable enum сервер помечаетrequiredсам). - Фильтр — операторы «равно/не равно» и селект значения (плюс «пусто/не пусто» для nullable).
Режимы загрузки. «Лента» (по умолчанию): порции догружаются при прокрутке (плюс кнопка «Показать ещё»); «Страницы» — классический пейджер. Размер порции/страницы — 25/50/100/200, по умолчанию 100. Режим и размер — глобальная настройка пользователя, localStorage-ключи ecosystem.listview.mode и ecosystem.listview.pageSize.
Массивы (простые коллекции)
Свойство-коллекция скаляров или enum (T[] либо List<T>; с Core-Api 0.12.0) получает один компонент на все случаи — viewComponent: { name: 'Array', params: { element: { name, params } } }, где element это готовый дескриптор компонента элемента (String, String с kind: 'guid', Int, Number, Enum со своими values); хелпер — elementComponent(params). В данных, патчах и фильтрах массив ходит JSON-массивом, enum внутри него — числами.
- Ячейка списка (
CellArray) — чипы значений: первые три целиком, остальные сворачиваются в «+N» с подсказкой (строка списка одна, длинный массив её разорвал бы). Enum-элементы рендеритCellEnum, поэтому иконка и цвет значения работают и внутри массива. - Редактор карточки (
EditorArray) — enum редактируется группой флажков (вид не настраивается:[EnumView]к коллекциям неприменим и роняет регистрацию), остальное набирается тегами: значение подтверждается Enter, запятой (у дробных чисел она десятичный разделитель, поэтому только Enter) и уходом из поля, Backspace на пустом поле снимает последний чип. Дубликаты не добавляются, нераспознанный ввод остаётся в поле с подсказкой. - Фильтр — «содержит» (точное совпадение элемента; в отличие от строковых колонок это не подстрока и с учётом регистра) плюс «пусто»/«не пусто»: сервер считает пустыми и
null, и пустой массив. - Сортировки нет — заголовок такой колонки не кликабелен, сервер такой запрос отклоняет (400).
- Обязательность не поддерживается:
requiredу коллекции всегдаfalse([Required]на бэкенде игнорируется) — проверить её нечем ни серверу, ни браузеру.
Со стороны бэкенда: элементами поддержаны string, short/int/long, float/double/decimal, Guid и enum; bool, byte (в том числе byte[]), даты/времена, HashSet<T>, интерфейсы коллекций и nullable-элементы — нет. Non-nullable коллекции инициализируйте в модели (= []): патч не присылает значение для нетронутого поля, и без инициализатора запись упадёт на NOT NULL.
Подсказки полей (Core 0.58)
Свойство может нести короткое пояснение «что это и как заполнять»: атрибут [Hint("...")] на модели либо fluent .Hint("...") при регистрации (fluent приоритетнее; пустая строка снимает подсказку атрибута). Текст приезжает в конфиг карточки полем hint редактора, а DetailViewField рисует у подписи знак вопроса: наведение показывает поповер, клик его прикалывает (на тач-экране наведения нет). Поповер живёт в body (Teleport) и позиционируется fixed по координатам знака вопроса с зажимом в видимую область — иначе его резал бы overflow-hidden модалки, а у поля с краю он уходил бы за границу; скролл и ресайз его закрывают. Компонентам редакторов делать ничего не нужно — подпись и подсказку рисует поле.
Ссылки (Reference)
Ссылка — FK-свойство long (обязательная) или long? (необязательная), указывающее на другой справочник generic-API (механизм — с Core 0.18.0; форма объявления ниже — с 0.19.0: атрибут стал generic, произвольное display-свойство заменил интерфейс ILookup; у прежней формы потребителей не было, совместимость не тянулась).
Объявление одно и на FK-поле — атрибутом либо fluent при регистрации (fluent приоритетнее); навигационные свойства EF (Role? Role, коллекции) UI не видит, объявления «коллекцией» нет — источник истины один, FK:
[Description("Роль")]
[Reference<Role>]
public long RoleId { get; set; }
// либо при регистрации:
entities.Register<User>(e => e.Property(x => x.RoleId, p => p.Reference<Role>()));
Цель обязана быть справочником — реализовывать интерфейс ILookup (Ecosystem.Core.Dal.Abstractions) с единственным свойством Title, которым её записи представляются в чужих интерфейсах:
public class Role : BaseEntity, ILookup
{
[Description("Наименование")]
[ListView(Order = 15)]
[DetailView(Order = 15)]
public string Title { get; set; } = null!;
}
Это требование ловит компилятор — не-справочник в [Reference<T>] и .Reference<T>() не подставится. Подпись всегда именно Title: произвольного display-свойства нет сознательно, метода Display() в построителе больше не существует. Title обязан быть обычным публичным свойством, замапленным в колонку NOT NULL: подпись читается из БД запросом, поэтому вычисляемое Title => Name не годится (проверяется на старте).
На старте хоста проверяется остальное: цель зарегистрирована в generic-API; поле — скалярный long; пометка не поставлена на невидимое свойство или на навигацию (иначе она молча не сработала бы). Non-nullable ссылка автоматически обязательна — id 0 не бывает.
Связь для EF объявляется отдельно и как обычно — [Reference<T>] это слой представлений, EF его не видит:
public long RoleId { get; set; }
public Role? Role { get; set; } // ← навигацию видит EF (конвенция <Навигация>Id)
// либо явно в IEntityTypeConfiguration:
builder.HasOne(u => u.Role).WithMany().HasForeignKey(u => u.RoleId);
Полный паттерн — оба слоя: FK в БД даёт целостность, атрибут даёт UI. Атрибут без FK-констрейнта работать будет (существование цели ядро проверяет запросом), но целостность на уровне БД тогда никто не гарантирует.
В данных, патчах и фильтрах значение остаётся числом (id цели). Подписи сервер кладёт в служебную карту $display каждой строки выдачи ({"roleId": 3, "$display": {"roleId": "Администраторы"}}) одним батч-запросом на страницу; хелпер — referenceDisplay(row, field). Подпись не гейтится правом цели и резолвится и для мягко удалённой цели: это подпись значения в чужой записи, а не доступ к самой цели.
- Ячейка списка (
CellReference) — подпись из$display;null— «—», подписи нет —#id. - Редактор карточки (
EditorReference) — комбобокс с серверным поиском поTitleцели:GET /api/reference/{entity}/options?search|ids|take(право —<цель>.read; порция 1–50, по умолчанию 20;ids— точечный резолв подписей). Без права на цель пикер не наполнится (403), но текущее значение остаётся видно подписью или#id; необязательная ссылка очищается крестиком. - Фильтр — «равно / не равно», значение выбирается мини-пикером в поповере (тот же поиск); «пусто/не пусто» — у nullable-колонки. Подпись выбранного значения показывает чип.
- Сортировки нет — она шла бы по голому id, а не по подписи; заголовок колонки не кликабелен, сервер такой запрос отклоняет (400).
- Precheck записи — несуществующая (или мягко удалённая) цель в патче даёт дружелюбный 400 вместо ошибки FK из БД.
- Зависимый фильтр (Core 0.57) — если поле зарегистрировано с
ReferenceFilter<TTarget>(x => x.Fk, (owner, target) => …), конфиг редактора несётparams.filtered, и оба редактора ссылки шлют серверу контекст владельца (ReferenceOwnerContext: сущность, поле, снимок живой модели формы изuseDetailViewContext().values) — комбобокс черезPOST /api/reference/{entity}/options, таблица черезListQuery.referenceпикера. Сервер сужает выборку предикатом по текущим значениям формы и отвергает на записи цель вне условия. Хост формы безvaluesв контексте оставляет такие пикеры несуженными.
Обратная сторона — виджет «дети по ссылке». Из каждой входящей ссылки ядро выводит виджет children:{ребёнок}.{fkПоле} (компонент EntityChildren, уже зарегистрирован в реестре фронта) и кладёт его в палитру карточки цели — блок ставится человеком в настройке раскладки, как любой виджет. Внутри — обычный ListViewPage во встраиваемом режиме, зашито суженный до детей текущей записи (lockedFilters): фильтры и карточки работают как в полном списке. Тулбар — как у виджета связи M2M плюс создание: «Создать» (с 0.53.0) открывает обычную модальную карточку новой записи ребёнка с уже подставленным и замороженным FK на текущего родителя (остальные поля — как на странице ребёнка; предустановка уходит в запрос создания как есть), «Добавить» (с 0.34.0) открывает пикер существующих записей ребёнка (EntityPickerModal; уже привязанные приглушены) и вешает выбранные на текущего родителя, «Убрать» снимает их. Колонка FK в гриде скрыта: значение у всех строк одно. Что доступно, решает сервер по характеру ссылки. «Создать» есть, если FK задаётся на создании (не read-only), запись сущности не запрещена политикой (DenyWrites) и код ссылки её не выключил — Property(x => x.ParentId, p => p.ChildrenCreate(false)) там, где детей заводит только своя страница или доменная операция. Read-only FK generic-патч молча игнорирует — «Добавить» скрыто, пока сервис не даст доменный обработчик (см. «Расширение»); обязательное поле нечем обнулить — скрыто «Убрать». На форме создания вместо списка заглушка — записи ещё нет. Если один ребёнок ссылается на цель двумя полями, заголовки виджетов различаются полем.
Многие-ко-многим — явная join-сущность с двумя ссылками (UserRole { UserId, RoleId }, оба поля с [Reference<T>]): регистрируете её как обычную сущность — обе карточки получают по виджету её списка. Скрытых EF-many-to-many в generic-UI нет.
В секциях настроек [Reference<T>] не поддержан — fail-fast при регистрации секции (у настроек нет ни $display-обогащения, ни precheck'а патча).
API напрямую: entitiesApi.referenceOptions(entity, { search, ids, take }); тип ReferenceOption, хелпер referenceDisplay(row, field).
Раскладка карточки (настройка через UI)
Карточку можно разложить без правки кода: шестерёнка в панели действий карточки (страница и модалка) открывает рядом панель настройки — дерево «вкладка → секция → поле». Поля перетаскиваются мышью между любыми секциями и вкладками, вкладки и секции переставляются стрелками; у выбранного узла правятся свойства. Форма слева всё это время показывает черновик вживую (данные при этом не редактируются и не сохраняются), в БД раскладка попадает только по кнопке «Сохранить».
Что настраивается:
| Уровень | Настройки |
|---|---|
| Вкладка | заголовок, порядок; единственная вкладка полосой вкладок не рисуется |
| Секция | заголовок (пустой — секция без заголовка и разделителя), 1–3 колонки, «можно свернуть» + «свёрнута при открытии» (нужен заголовок), порядок |
| Поле | подпись (пусто — заголовок из кода), компонент, ширина в колонках секции, скрыть с карточки, положение |
Раскладка глобальная — одна на сервис, её видят все. Правку гейтит отдельное админское право viewlayout.write (выдаётся вкладкой «Доступы»): без него шестерёнки нет (config.canCustomize === false), но настроенную раскладку человек всё равно видит. <entity>.write даёт менять записи, а не внешний вид карточки.
Подмена компонента. В списке «Компонент» — все правила подбора, подошедшие этому свойству. Ядро кроме авто-подбора даёт строке «Текст (многострочный)» и «Код — <язык>» (см. ниже), а перечислению — явный вид (список/радиокнопки). Свой компонент добавляется парой: правило на бэкенде и реализация в реестре фронта — предикат тот же, приоритет не выше победившего, тогда авто-подбор не меняется, а вариант появляется в списке:
builder.Services.AddEntityApi(
entities => entities.Register<Article>(),
components => components.Register(
p => p.EffectiveType == typeof(string),
_ => new ViewComponentDescriptor("Markdown"),
key: "Markdown", title: "Markdown-редактор"));
registerEditor('Markdown', MyMarkdownEditor)
В раскладке хранится ключ варианта, а не дескриптор: параметры (значения enum и т.п.) пересобираются кодом на каждый запрос и не устаревают в БД.
Код с подсветкой. Строковое свойство можно рисовать редактором кода — вариант «Код — <язык>» (ключ Code:<язык>, компонент Code с params.language). Язык выбирается именно вариантом компонента, потому что в раскладке хранится ключ, а не параметры; набор — bash (команды Linux), sql, json, yaml, xml, ini, diff, markdown, makefile, C#, Java, JavaScript, TypeScript, Python, Go, PHP, Ruby, Rust, Kotlin, Swift, Lua, C, C++, css, scss, graphql и «без подсветки». Поле остаётся обычной строкой: тип данных, патчи, фильтры и конкуррентность те же, меняется только ввод — моноширинный, с номерами строк, подсветкой и горизонтальным скроллом вместо переноса; Tab, как и в остальных полях формы, уводит фокус, а не ставит отступ. В списке колонка показывает первую непустую строку.
Что ещё умеет редактор:
- парные скобки — когда каретка стоит у
(,[или{(или у закрывающей), обе половины пары подсвечиваются синим, непарная — красным; скобки внутри строк и комментариев не исключаются (для этого пришлось бы разбирать язык); - поиск по полю —
Ctrl+F(ловится по физической клавише, поэтому русская раскладка не мешает) либо кнопка-лупа: все совпадения жёлтым, текущее оранжевым, счётчик «3 / 12». «Найти далее» / «найти предыдущее» —Enter/Shift+EnterиF3/Shift+F3, кнопки со стрелками; «найти все» — кнопка со списком: строки с совпадениями (номер + текст), клик по строке переводит к ней.Escзакрывает панель и возвращает каретку на найденное. Поиск без учёта регистра, без regex, максимум 500 совпадений; - перенос длинных строк — кнопка-переключатель: вместо горизонтального скролла строки ломаются по ширине поля (режим чтения логов и длинного JSON). Номера строк при этом скрываются — одна логическая строка занимает несколько визуальных, и номер напротив каждой врал бы;
- на весь экран — кнопка разворачивает поле поверх страницы (
Esc— свернуть). Поле уезжает вbodyтелепортом, поэтому режим работает и внутри модальной карточки.
Кнопки проявляются в правом верхнем углу поля по наведению и фокусу, чтобы не занимать место у каждого поля карточки. Всё это работает и в нередактируемом поле — там нет только ввода.
Подсветку даёт highlight.js (зависимость пакета) — её распространённый комплект грамматик, который грузится отдельным чанком при первом появлении такого поля. Языка комплекта не хватило — грамматика добавляется приложением, имя обязано совпадать с language варианта:
import { registerCodeLanguage } from '@ecosystem/ui-core/highlight'
import powershell from 'highlight.js/lib/languages/powershell'
registerCodeLanguage('powershell', powershell)
Незнакомый фронтенду язык не ломает поле — оно работает как моноширинный ввод без раскраски.
Код остаётся источником истины о наборе полей, их типах, обязательности и правах — раскладка говорит только о расположении, подписи и выборе компонента. Расхождение с кодом никогда не ломает карточку и лечится молча: исчезнувшее свойство выпадает из раскладки, новое дописывается в конец последней секции, ставшее обязательным скрытое поле возвращается на карточку, пропавший вариант компонента откатывается к авто-подбору. При сохранении, наоборот, всё проверяется строго (400 с текстом): обязательное поле скрыть нельзя, поле не может стоять дважды, ширина обязана влезать в сетку, хотя бы одно поле должно остаться видимым.
Скрытое раскладкой поле не попадает в config.editors — значит, и в патч сохранения. Служебные поля (id, lastUpdateAt, isDeleted) размечены в ядре как скрытые по умолчанию: в дереве настройки они есть (с закрытым глазом) и включаются одним кликом, но на карточке до этого не показываются. Нередактируемые поля вообще не рисуются на форме создания — значения у новой записи ещё нет, а задать его нельзя.
Подключение на стороне сервиса (иначе шестерёнки нет и всё работает как раньше): AddEntityApi(...).AddEntityLayouts(), modelBuilder.ApplyViewLayoutsModel() в контексте и миграция под таблицу ViewLayouts.
API доступен и напрямую: entitiesApi.getLayoutDesigner(entity) / saveLayout(entity, { layout, lastUpdateAt }) / resetLayout(entity); invalidateDetailViewConfig(entity) сбрасывает кэш конфига после правки. Типы: DetailViewLayout, DesignerLayout, DetailViewDesigner, DesignerField. Сохранение с lastUpdateAt даёт честный 409 при параллельной правке — панель предлагает «Перечитать».
lastUpdateAt— непрозрачный токен версии (везде:EntityRow,SettingsValues, раскладка; типRowVersion, с Core 0.14.0). Это ISO-дата момента изменения строки в UTC, и клиенту её не надо интерпретировать: полученное значение уходит обратно как есть. Не разбирайте его вDateдля отправки — округление до миллисекунды сломает сверку версий (для показа даты разбирайте копию). До 0.14.0 версией былDateTime.Ticks, приезжавший строкой по той же причине: число ≈ 6.4·10¹⁷ не влезает в double браузера (Number.MAX_SAFE_INTEGER≈ 9·10¹⁵) и возвращалось изменённым в младших разрядах, роняя каждое второе сохранение в «конфликт версий».
Компонент панели экспортируется отдельно (DetailViewDesigner, пропс entity, события close/saved/preview) — как и DetailViewField (одно поле: подпись + редактор из реестра).
Размер модальной карточки
Модальную карточку можно растянуть мышью: тянутся все четыре края и угла — у границы курсор меняется на «растяжку», дальше зажать и вести. Окно всегда остаётся по центру экрана, поэтому край идёт ровно за курсором, а противоположная сторона отъезжает симметрично. Двойной клик по краю возвращает размер по умолчанию.
Размер запоминается за учёткой и на каждую сущность отдельно — карточки пользователя и роли открываются каждая своим размером, и одинаково с любого устройства. Хранение — личная секция настроек userpreferences, служебное поле detailViewSizes (на бэкенде размечено [SettingsState]): на странице настроек его нет, пишет его сам кабинет. Нужен подключённый Ecosystem.Core.Web.Settings (Core ≥ 0.17.0); без него растягивание работает, но размер живёт только до перезагрузки страницы.
Границы: не меньше 360×240 и не больше экрана (по высоте — те же 90 %, что у окна по умолчанию); пока открыта панель настройки раскладки, окно временно не уже 960 px — эта прибавка в сохранённый размер не попадает. У отдельной страницы карточки размера нет — она и так во всю ширину.
Колонки списка
Список настраивается тем же способом и тем же правом: шестерёнка в тулбаре (config.canCustomize) открывает рядом панель ListViewDesigner — колонки в порядке отображения, каждая перетаскивается мышью или двигается стрелками, глазик скрывает её, у выбранной правится подпись. Сохранение — та же таблица ViewLayouts под видом listview, тот же 409 с «Перечитать», та же кнопка «Сбросить к коду».
Колонки, размеченные в ядре как скрытые по умолчанию ([ListView(DefaultVisibility = false)] — в том числе id, lastUpdateAt, isDeleted), в списке не показываются, пока их не включат здесь. Скрытая колонка не приходит в config.columns вовсе — значит, по ней нет ни фильтра, ни сортировки; после сохранения страница перечитывает конфиг и сбрасывает активные фильтры и сортировки.
API напрямую: entitiesApi.getListLayoutDesigner(entity) / saveListLayout(entity, { layout, lastUpdateAt }) / resetListLayout(entity); invalidateListViewConfig(entity) сбрасывает кэш конфига. Типы: ListViewLayout, LayoutColumn, ListViewDesigner, DesignerColumn.
Реестр сущностей (каталог в БД)
Generic-API может вести список зарегистрированных сущностей прямо в БД — таблица Entities (с Core 0.18.0, opt-in): строка на сущность — системное имя, заголовок, признак «объявлена кодом сейчас». Направление одностороннее — код диктует, БД отражает: синхронизация на старте добавляет новое, оживляет вернувшееся и помечает снятое с регистрации неактивным. Строки не удаляются (на имя будут ссылаться внешними ключами сателлиты — раскладки, персональные предпочтения), а ссылаться принято по имени (альтернативный ключ): оно одинаково во всех средах, тогда как Id раздаёт конкретная база.
builder.Services.AddEntityApi(entities => entities
.Register<Role>()
.UseCatalog());
// В OnModelCreating контекста + миграция:
modelBuilder.ApplyEntityCatalogModel();
// На старте, после миграций:
await app.Services.SyncEntityCatalogAsync();
UseCatalog() заодно регистрирует строку реестра обычной сущностью generic-API — кабинет получает страницу «Сущности» (право entityinfo.read) без единой строчки UI-кода; все поля строки read-only, истина остаётся в коде. Без UseCatalog() всё работает как раньше: реестр живёт только в памяти процесса.
Строка реестра — сама справочник (EntityInfo : ILookup, подпись — её «Наименование»), поэтому на неё можно ссылаться из своих сущностей обычным [Reference<EntityInfo>].
Логотип (BrandLogo)
BrandLogo — брендовый локап Qua8ion EcoSystem (дословная разметка фирменного ассета) с подписью сервиса под ним. AppShell рендерит его в шапке сайдбара и мобильного топбара по умолчанию; переопределение — тот же слот #logo. Поведение:
- Подпись сервиса приходит с
GET /api/service/name(Core-Api ≥ 0.5.0, секцияService:Nameв appsettings хоста; анонимный эндпоинт — подпись видна и до входа) и кэшируется на модуль; переопределяется пропомservice-name. Пустое имя — строка подписи не рендерится. - Клик по логотипу ведёт на главную (
to, по умолчанию/);:to="null"— логотип не ссылка. - Ховер по подписи показывает тултип с тех.информацией (
GET /api/service/info: версия билда, имя БД, СУБД) — только пользователям с правомservice.info(право объявляется ядром черезIPermissionSourceи выдаётся вкладкой «Доступы»); без права запрос отдаёт 403 и тултип не появляется. - Проп
dark— светлый/тёмный вариант текста (как у ассета).
API сервиса доступен и напрямую: serviceApi.getName()/getInfo(), getServiceName() (кэш), тип ServiceInfo.
Оболочка (AppShell)
AppShell — каркас кабинета: тёмный сайдбар с меню (на мобильных — drawer с топбаром) и светлая область контента. Меню приложение собирает само (модель MenuItem; пункт с children — группа-секция), динамическую группу «по сущностям» даёт useEntityMenu() — пункты-ссылки на listview для всего из GET /api/entities (ответ кэшируется на уровне модуля: сайдбар и главная делят один запрос; reload() сбрасывает кэш).
<script setup lang="ts">
import { AppShell, useEntityMenu, IconHome, IconTable, type MenuItem } from '@ecosystem/ui-core'
const entities = useEntityMenu({ icon: IconTable })
const menu = computed<MenuItem[]>(() => [
{ label: 'Главная', to: { name: 'home' }, icon: IconHome, exact: true },
...(entities.items.value.length ? [{ label: 'Данные', children: entities.items.value }] : [])
])
</script>
<template>
<AppShell :menu="menu">
<template #logo><!-- логотип приложения --></template>
<template #footer><!-- пользователь, кнопка выхода --></template>
<RouterView />
</AppShell>
</template>
Главная страница: по умолчанию монтируйте WelcomePage (простое приветствие, пропсы title/subtitle + слот) на свой home-роут; сервис со своей главной просто ставит на этот роут собственный компонент — ядро механики переопределения не требует. Иконки — встроенные inline-SVG компоненты (IconHome, IconUser, IconKey, IconMail, IconShield, IconTable, IconLogout, IconMenu, IconX, IconExpand, IconPlus, IconPencil, IconTrash, IconRefresh, IconFilter, IconBell), размер — классом (class="size-4"); подойдёт и любой свой SVG-компонент.
Справа в топбаре (белом на desktop, тёмном на мобильных) оболочка всегда рендерит колокольчик уведомлений — см. следующий раздел; без настройки он не виден.
Уведомления (колокольчик)
Уведомления — функция ядра (с 0.54.0): любой сервис издаёт их в шину (INotificationPublisher бэкенда), сервис уведомлений ведёт журнал и отдаёт ленту текущего пользователя по HTTP, а кабинет каждого сервиса показывает её колокольчиком в топбаре AppShell (NotificationBell, ставится оболочкой сам). Лента живёт на другом origin, поэтому у колокольчика свой адрес, независимый от baseUrl entity-API; включается он один раз при старте, после configureEntityHttp:
import { configureEntityHttp, configureNotifications } from '@ecosystem/ui-core'
configureEntityHttp({ ... })
if (config.notificationsUrl) {
configureNotifications({
baseUrl: config.notificationsUrl, // origin сервиса уведомлений без завершающего «/»; '' — same-origin
// cabinetUrl: куда ведёт «Все уведомления» (по умолчанию baseUrl || '/')
// pollIntervalMs: период опроса ленты (по умолчанию 30000)
})
}
Без вызова configureNotifications колокольчик не рисуется вовсе и в сеть не ходит — кабинет без сервиса уведомлений ничего не замечает. Адрес берётся по общей схеме конфигурации кабинета: runtime /config.json → поле notificationsUrl, дефолт для сборки — VITE_NOTIFICATIONS_URL в .env.production (сам кабинет уведомлений передаёт '').
Аутентификация — та же, что у entity-API: bearer из getAccessToken либо кука по credentials из configureEntityHttp (audience токена Identity общий у всех ресурсных API, поэтому чужой сервис принимает тот же токен). Запрос кросс-доменный — на сервисе уведомлений должен быть разрешён CORS для origin'ов кабинетов: секция Cors ядра, env-форма Cors__AllowedOrigins__0=https://id.qua8ion.com, Cors__AllowedOrigins__1=… (по одному на кабинет; без неё браузер запросы не пустит, а колокольчик просто спрячет бейдж).
Контракт API сервиса уведомлений (клиент — notificationsApi):
| Запрос | Ответ | Клиент |
|---|---|---|
GET /api/my/notifications?take=20 |
{ items: NotificationItem[]; unreadCount: number } |
list(take?) — им же идёт фоновый опрос (take=10) |
GET /api/my/notifications/unread-count |
{ unreadCount: number } |
unreadCount() |
POST /api/my/notifications/{id}/read |
204 | markRead(id) |
POST /api/my/notifications/read-all |
204 | markAllRead() |
NotificationItem: id, title, body (простой текст без разметки), severity (0 Info · 1 Warning · 2 Error · 3 Critical), source (ключ сервиса-издателя), category («сервис.объект.событие»), link (абсолютный URL «Открыть» или null), receivedAt, readAt (null — не прочитано), system (системное оповещение без адресата).
Поведение колокольчика:
- бейдж непрочитанных (
99+при переполнении); лента опрашивается каждыеpollIntervalMs— один опрос на кабинет, сколько бы колокольчиков ни стояло в оболочке (мобильный и desktop-топбар делят состояние); на скрытой вкладке (document.hidden) опрос приостанавливается, при возврате обновляется сразу. Опрашивается именно список, а не голый счётчик: из него же видно, что пришло, — иначе всплывающую карточку было бы нечем наполнить; - клик открывает поповер с последними 20: точка цвета важности (синий · янтарный · красный · тёмно-красный), заголовок (жирный у непрочитанного), текст в две строки, подпись
source · category, относительное время («5 мин назад», «вчера»; точное — в подсказке); - клик по уведомлению помечает его прочитанным (оптимистично) и открывает
linkв новой вкладке, если он есть; «Прочитать все» — разом; «Все уведомления» ведёт наcabinetUrl(новой вкладкой, если это другой origin); - закрытие — Escape (фокус возвращается на кнопку) или клик вне; кнопка с
aria-label, поповер —role="dialog", элементы — кнопки, всё доступно с клавиатуры; - ошибки (сеть, 401/403, нет CORS) — тихо: бейдж прячется, в консоль уходит один
console.debugна серию сбоев;onUnauthorizedклиента при этом не вызывается — 401 чужого сервиса не означает, что сессия кабинета истекла.
Всплывающие карточки и звук (с 0.55.0). Уведомление, появившееся между опросами, всплывает карточкой в правом нижнем углу и подаёт короткий сигнал:
- карточки рисует
NotificationToasts— оболочка монтирует его один раз (колокольчиков в разметке два, всплывать должно однократно); до трёх штук одновременно, каждая живёт 6 секунд, клик = клик по строке в поповере (помечает прочитанным, открываетlink), крестик убирает сразу; открытие поповера убирает все — там то же самое, только подробнее; - первый ответ после загрузки страницы ничего не всплывает: он лишь запоминает, что уже в ленте, иначе человек получал бы пачку карточек о вчерашнем при каждом открытии кабинета. Прочитанное в другой вкладке тоже не всплывает;
- звук — синтезированный «дзынь» (
sound.ts, WebAudio, два тона): ни одного файла в пакете, правится строкой кода. Один сигнал на пачку, а не на каждое уведомление; - автовоспроизведение: браузеры держат аудиоконтекст замороженным, пока на странице не было клика или нажатия клавиши, — самый первый сигнал в свежей вкладке молча пропадёт. Контекст размораживается на первом же жесте, отдельного разрешения не спрашиваем;
- тумблер звука — иконка динамика в шапке поповера; выбор личный и хранится в
localStorage(ecosystem.notifications.sound), серверу не нужен. Программно —soundEnabled/setSoundEnabled(v), разовый сигнал —playNotificationSound().
Типы: NotificationsOptions, NotificationItem, NotificationFeed, NotificationSeverity; текущие настройки — notificationsOptions() (null — колокольчик выключен); иконки — IconBell, IconVolume, IconVolumeOff.
Настройки (SettingsPage / SettingsForm)
Страница настроек (требует Core-Api ≥ 0.6.1: пакет Ecosystem.Core.Web.Settings) — один экран с вкладками «Личные»/«Глобальные»: слева секции активной вкладки, сгруппированные по категориям (капс-заголовок над группой; секции без категории — первыми, категория задаётся [SettingsSection(Category = "...")] либо fluent .Category()), справа форма выбранной. Личные (/api/settings/my) доступны каждому вошедшему; глобальные (/api/settings) — настройка стенда: вкладка видна только обладателям права settings.read (403 от сервера прячет её), запись — settings.write.
Маршрут-контракт один (вкладка — param scope, выбранная секция — param type; без валидного type страница сама уходит на первую секцию вкладки):
import { SettingsPage } from '@ecosystem/ui-core'
const routes = [
{ path: '/settings/:scope(global|my)?/:type?', name: 'settings', component: SettingsPage,
props: r => ({ scope: r.params.scope as 'global' | 'my' | undefined, type: r.params.type as string | undefined }) }
]
(До 0.6.1 контрактом были два маршрута settings/settings-my — при обновлении старый путь личных настроек сведите редиректом на { name: 'settings', params: { scope: 'my' } }.)
Форма (SettingsForm, экспортируется отдельно: пропсы scope/type/variant, события config-loaded/saved) строится из общего реестра редакторов; сохранение шлёт частичный PATCH с lastUpdateAt (конфликт — 409 с кнопкой «Перечитать»).
Секреты (write-only). Поле с серверным компонентом Secret (fluent .Secret() при регистрации секции) — маскированный ввод: сервер значение никогда не возвращает (в values оно null, «заданность» — в secretsSet), пустое поле при сохранении значение не меняет, кнопка «Очистить» шлёт null. Редактор EditorSecret зарегистрирован в реестре под именем Secret.
Состояние интерфейса. Свойство секции с [SettingsState] (fluent .State()) на форме не рисуется, но ездит в values и патчах — это служебное состояние кабинета за учёткой, которое пишет не человек, а сам UI; хранится строкой JSON, формат знает только фронтенд. Так живут размеры модальных карточек (detailViewSizes в базовой личной секции userpreferences).
API доступен и напрямую: settingsApi(scope).list()/viewConfig(type)/get(type)/update(type, patch); анонимная проекция секции с .Public() — getPublicSettings(type) (например, для welcome-контента до входа). Типы: SettingsListItem, SettingsViewConfig, SettingsValues, SettingsScope.
Планировщик (JobsPage)
Страница «Планировщик» (требует Core-Api ≥ 0.30.0: пакет Ecosystem.Core.Jobs подключён в хосте) — штатный дашборд Hangfire во фрейме кабинета. Вход тикетный: страница берёт одноразовый тикет (POST /api/jobs/dashboard-ticket, обычный авторизованный вызов клиента), фрейм открывает /hangfire/auth?ticket=… — сервер обменивает тикет на куку сеанса (час, только путь /hangfire) и редиректит в дашборд, поэтому работает и на bearer-, и на куки-хостах. Права: вход — jobs.read (без него страница сообщает об отсутствии доступа), мутации дашборда («запустить сейчас», requeue, удаление) — jobs.write, иначе дашборд в режиме чтения (решает сервер). Оба права объявляются ядром через IPermissionSource и выдаются вкладкой «Доступы».
Со стороны бекенда дашборд включается app.UseCoreJobsDashboard() до конвейера хоста (UseCoreService/UseIdentityServer) — иначе FallbackPolicy ресурсного профиля отбивает /hangfire раньше дашборда, и фрейм показывает пустой 401.
Маршрут-контракт и пункт меню (сайдбар собирает приложение):
import { JobsPage } from '@ecosystem/ui-core'
const routes = [{ path: '/jobs', name: 'jobs', component: JobsPage }]
Роли и права (локальный RBAC)
Страницы «Роли» и «Права» отдельного кода в SPA не требуют: с ядра 0.48.0 локальный RBAC сервиса (Ecosystem.Core.Web.Rbac: AddCoreRbac() + entities.UseRbac(menuOrder) + ApplyRbacModel() + SeedRbacAsync() + миграция) публикует роли Identity, права сервиса и связку «роль ↔ право» обычными generic-сущностями — как в Identity. «Роли» попадают в меню сущностей (useEntityMenu), права роли выдаются виджетом связи ManyToMany на её карточке (ставится шестерёнкой раскладки; «Добавить» открывает пикер справочника «Права», «Убрать» снимает связь). Роль-администратор несёт wildcard «*» и не редактируется; прочие роли появляются сами (зеркало учёток, JIT по визиту носителя) либо заводятся по имени кнопкой «Создать». Права — role.read|write, permission.read, rolepermission.read|write; вступление правок в силу — до ~30 секунд (кэш резолвера). Прежняя страница-матрица RbacPage (0.35–0.47) и rbacApi сняты.
Личные доступы (с 0.49.0): виджет «Доступы» (компонент UserAccess, ключ user-access) ядро объявляет для карточки учётки само (UseRbac), на карточку его ставят шестерёнкой; таблица «право · от роли · переопределение (Роль/Разрешить/Запретить) · итог» — как в Identity, пишет ручкой PUT /api/rbac/users/{id}/access (право <user>.write, антиэскалация: выдать можно только право, которым владеешь сам). Клиент — userAccessApi.get(userId)/set(userId, grants, denies).
Tailwind
Разметка компонентов — на Tailwind 4. В приложении-потребителе:
- Добавьте исходники пакета в сканирование:
@source "../node_modules/@ecosystem/ui-core/src";(или путь доui/srcядра при file:-установке). - Определите брендовые токены темы, которые использует разметка:
--color-brand-500,--color-brand-600,--color-brand-700(см.@themeTailwind 4).
Расширение
Серверный ViewComponent.Name резолвится через реестр; свои компоненты добавляются точечно:
import { registerCell, registerEditor } from '@ecosystem/ui-core'
registerCell('Image', MyImageCell)
registerEditor('CodeEditor', MyCodeEditor)
Компонент не зарегистрирован — поле не рендерится (deny by default, как на сервере).
Доменные редакторы
Поле, которое меняется только своим доменным API — с его инвариантами и защитами (смена роли, перевод статуса, публикация), — generic-патч возить не должен: иначе патч обойдёт проверки контроллера. Схема такая: свойство размечается ReadOnly, а его компонент сам зовёт нужный эндпоинт и просит форму перечитать запись.
[Description("Роль")]
[DetailView(Order = 45, ReadOnly = true)]
public long? RoleId { get; set; }
// Приоритет выше дефолтных правил ядра (0 у скаляров, 10 у перечислений и коллекций),
// поэтому правило побеждает авто-подбор, а не просто добавляет вариант в список.
builder.Services.AddEntityApi(
entities => entities.Register<User>(),
components => components.Register(
p => p.Property.DeclaringType == typeof(User) && p.Name == nameof(User.RoleId),
_ => new ViewComponentDescriptor("UserRole"),
priority: 20, title: "Роль"));
registerEditor('UserRole', UserRoleEditor)
Состояние формы редактор берёт через useDetailViewContext():
| Поле | Значение |
|---|---|
entity, id, isNew |
сущность и запись карточки; id === null — форма создания |
record |
загруженная запись — у ReadOnly-поля значение приходит отсюда |
canWrite |
право <entity>.write из конфига |
preview |
открыт черновик панели настройки раскладки |
saving |
форма сохраняет или удаляет запись |
reload() |
тихо перечитать запись: обновит значения и версию строки, не тронув правки в других полях |
notifyChanged() |
сообщить хосту, что запись изменена (модалка передаст это списку) |
- Свойство обязано быть
ReadOnly— иначе значение уедет ещё и в патч. Отсюда же следствия: на форме создания поля нет, значение читается изrecord. - Проп
disabledтакому редактору всегда приходитtrue(то же следствиеReadOnly) — доменный редактор его игнорирует и решает интерактивность сам:!preview && canWriteплюс готовность собственных данных. - Контекста может не быть (
null) — реестр общий, тот же компонент могут смонтировать вне карточки. Без контекста — только чтение. - В
previewмутации запрещены: панель настройки раскладки не должна менять данные (читать справочники можно). - После успешной мутации —
await ctx.reload(), затемctx.notifyChanged(). Безreload()поле покажет старое значение, а следующее «Сохранить» упрётся в ложный конфликт версий — у формы останется устаревшийlastUpdateAt. - Имя компонента глобально на весь реестр: называйте по домену (
UserRole) и не пересекайтесь с именами ядра (String,Enum,Secret, …).
Ячейка списка для того же поля регистрируется обычным registerCell под тем же именем — правило подбора компонента у списка и карточки общее. Сервис, который рисует поля собственным хостом вместо DetailViewForm, может опубликовать контекст сам: provideDetailViewContext(...).
Виджеты карточки
Доменный редактор из предыдущего раздела привязан к свойству. Когда блоку соответствовать нечему — таблица доступов, список устройств, история — это виджет: он объявляется каталогом на бэкенде, реализуется компонентом на фронте, а на карточку его ставит человек в настройке раскладки. Значения в записи у виджета нет: он не участвует в патче, а данные берёт и пишет сам.
builder.Services.AddEntityApi(
entities => entities.Register<User>(),
components: null,
configureWidgets: widgets => widgets.Register(
key: "user-access",
title: "Доступы",
component: "UserPermissions",
criteria: entity => entity.ClrType == typeof(User)));
registerWidget('UserPermissions', UserPermissionsWidget)
Компонент виджета получает пропс widget (key, title, viewComponent.params — ими сервис настраивает один и тот же блок в разных местах) и берёт состояние карточки из того же useDetailViewContext().
Размещение — шестерёнка карточки: в панели настройки появляется палитра «Виджеты» с доступными блоками, клик ставит блок в выделенную секцию, дальше он таскается мышью наравне с полями, ему задаются заголовок и ширина, а крестик убирает его с карточки. Правится это без кода: код объявляет, что виджет существует, раскладка — где он стоит.
Инварианты те же, что и у полей: раскладка ссылается на ключ виджета, а параметры пересобираются кодом на каждый запрос; снятый с регистрации виджет молча выпадает из сохранённой раскладки; поставить один и тот же виджет дважды нельзя (400 при сохранении). Разница одна: новое поле кода дописывается в раскладку само, а виджет — нет, его набор определяет не код, а человек.
Встроенный виджет ядра — EntityChildren («дети по ссылке»): объявлять его не нужно, он появляется в палитре сам у сущностей, на которые указывает чья-то ссылка, — см. раздел «Ссылки».
Доменная привязка в виджете «дети по ссылке»
Виджет вешает и снимает записи патчем FK ребёнка. Если FK доменный — размечен [DetailView(ReadOnly = true)], потому что менять его вправе только своё API с его защитами, — патч ядро молча игнорирует, и без обработчика виджет прячет «Добавить»/«Убрать». Обработчик подставляет доменный вызов вместо патча (регистрация — пара «сущность-ребёнок + json-имя FK», то есть ровно та связь, которую представляет виджет):
registerChildLinkHandler('user', 'roleId', {
link: async ({ id, parentId, parent }) => {
const user = await entitiesApi.get('user', id)
await rolesApi.setUserRole(String(user.userName), String(parent?.name ?? ''))
}
// unlink не объявлен — «Убрать» в этом виджете скрыто
})
Операция — на одну запись: батч, порядок и разбор частичных отказов остаются за виджетом (пикер держится открытым с остатком и текстом ошибок). Объявлена только link — доступно только «Добавить»; отсутствие операции означает «так делать нельзя».
Свои действия списка
Реестр действий тулбара глобальный: registerListAction добавляет действие во все списки, повторная регистрация с тем же key замещает прежнее (в т.ч. встроенное), unregisterListAction(key) убирает. Область действия сужается предикатом criteria (сущность, права); порядок в тулбаре — числом order. Контекст ListActionContext даёт entity/config/rows/selectedIds/selectedRows, операции reload/openCreate(presets?) (предустановки формы создания — поля заморожены, значения уходят в запрос как есть)/openDetail/clearSelection/setError и обёртку runBusy (блокирует тулбар на время работы).
import { registerListAction } from '@ecosystem/ui-core'
// Кнопка только для одной сущности: активна при выборе строк.
registerListAction({
key: 'export-users',
order: 50,
label: 'Выгрузить',
criteria: ctx => ctx.entity === 'user',
requiresSelection: true,
execute: ctx => ctx.runBusy(() => exportUsers(ctx.selectedIds))
})
Действию с собственным диалогом вместо execute задаётся component: при клике он монтируется с пропсом ctx и событием close и сам рисует свой диалог (Teleport в body — образец DetailViewModal). Скрыть встроенное действие на конкретной странице — проп hidden-actions='["delete"]'; заместить глобально — зарегистрировать свой вариант с тем же ключом (например, delete с criteria: ctx => ctx.entity !== 'role').
Dependencies
Dependencies
| ID | Version |
|---|---|
| @microsoft/signalr | ^8.0.7 |
| highlight.js | ^11.12.0 |
Development dependencies
| ID | Version |
|---|---|
| @tanstack/vue-table | ^8.20.5 |
| @vue/tsconfig | ^0.7.0 |
| typescript | ~5.8.3 |
| vue | ^3.5.13 |
| vue-router | ^4.5.0 |
| vue-tsc | ^2.1.10 |
Peer dependencies
| ID | Version |
|---|---|
| @tanstack/vue-table | ^8.20.5 |
| vue | ^3.5.13 |
| vue-router | ^4.5.0 |