@ecosystem/ui-core (0.18.0)
Installation
@ecosystem:registry=npm install @ecosystem/ui-core@0.18.0"@ecosystem/ui-core": "0.18.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 куки-хостов
})
Маршруты-контракт
Компоненты навигируют по именам маршрутов 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 | равно, не равно (значение — мини-пикер: поиск по display-свойству цели) |
«Пусто/не пусто» (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.
Ссылки (Reference)
Ссылка — FK-свойство long (обязательная) или long? (необязательная), указывающее на другую сущность generic-API (с Core 0.18.0). Объявление одно и на FK-поле — атрибутом либо fluent при регистрации (fluent приоритетнее); навигационные свойства EF (Role? Role, коллекции) UI не видит, объявления «коллекцией» нет — источник истины один, FK:
[Description("Роль")]
[Reference(typeof(Role))]
public long RoleId { get; set; }
// либо при регистрации:
entities.Register<User>(e => e.Property(x => x.RoleId, p => p.Reference<Role>()));
Цель обязана быть зарегистрирована и иметь display-свойство — строку, которой её записи представляются в чужих интерфейсах:
entities.Register<Role>(e => e.Display(x => x.Title));
Оба требования (и «поле — скалярный long») проверяются на старте хоста fail-fast'ом; non-nullable ссылка автоматически обязательна — id 0 не бывает.
В данных, патчах и фильтрах значение остаётся числом (id цели). Подписи сервер кладёт в служебную карту $display каждой строки выдачи ({"roleId": 3, "$display": {"roleId": "Администраторы"}}) одним батч-запросом на страницу; хелпер — referenceDisplay(row, field). Подпись не гейтится правом цели и резолвится и для мягко удалённой цели: это подпись значения в чужой записи, а не доступ к самой цели.
- Ячейка списка (
CellReference) — подпись из$display;null— «—», подписи нет —#id. - Редактор карточки (
EditorReference) — комбобокс с серверным поиском по display-свойству цели:GET /api/reference/{entity}/options?search|ids|take(право —<цель>.read; порция 1–50, по умолчанию 20;ids— точечный резолв подписей). Без права на цель пикер не наполнится (403), но текущее значение остаётся видно подписью или#id; необязательная ссылка очищается крестиком. - Фильтр — «равно / не равно», значение выбирается мини-пикером в поповере (тот же поиск); «пусто/не пусто» — у nullable-колонки. Подпись выбранного значения показывает чип.
- Сортировки нет — она шла бы по голому id, а не по подписи; заголовок колонки не кликабелен, сервер такой запрос отклоняет (400).
- Precheck записи — несуществующая (или мягко удалённая) цель в патче даёт дружелюбный 400 вместо ошибки FK из БД.
Обратная сторона — виджет «дети по ссылке». Из каждой входящей ссылки ядро выводит виджет children:{ребёнок}.{fkПоле} (компонент EntityChildren, уже зарегистрирован в реестре фронта) и кладёт его в палитру карточки цели — блок ставится человеком в настройке раскладки, как любой виджет. Внутри — обычный ListViewPage во встраиваемом режиме, зашито суженный до детей текущей записи (lockedFilters): фильтры и карточки работают как в полном списке (создание — тоже, но FK новой записи пока заполняется вручную). На форме создания вместо списка заглушка — записи ещё нет. Если один ребёнок ссылается на цель двумя полями, заголовки виджетов различаются полем.
Многие-ко-многим — явная join-сущность с двумя ссылками (UserRole { UserId, RoleId }, оба поля с [Reference]): регистрируете её как обычную сущность — обе карточки получают по виджету её списка. Скрытых EF-many-to-many в generic-UI нет.
В секциях настроек [Reference] не поддержан — 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 и т.п.) пересобираются кодом на каждый запрос и не устаревают в БД.
Код остаётся источником истины о наборе полей, их типах, обязательности и правах — раскладка говорит только о расположении, подписи и выборе компонента. Расхождение с кодом никогда не ломает карточку и лечится молча: исчезнувшее свойство выпадает из раскладки, новое дописывается в конец последней секции, ставшее обязательным скрытое поле возвращается на карточку, пропавший вариант компонента откатывается к авто-подбору. При сохранении, наоборот, всё проверяется строго (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>(e => e.Display(x => x.Title))
.UseCatalog());
// В OnModelCreating контекста + миграция:
modelBuilder.ApplyEntityCatalogModel();
// На старте, после миграций:
await app.Services.SyncEntityCatalogAsync();
UseCatalog() заодно регистрирует строку реестра обычной сущностью generic-API — кабинет получает страницу «Сущности» (право entityinfo.read) без единой строчки UI-кода; все поля строки read-only, истина остаётся в коде. Без UseCatalog() всё работает как раньше: реестр живёт только в памяти процесса.
Логотип (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), размер — классом (class="size-4"); подойдёт и любой свой SVG-компонент.
Настройки (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.
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 («дети по ссылке»): объявлять его не нужно, он появляется в палитре сам у сущностей, на которые указывает чья-то ссылка, — см. раздел «Ссылки».
Свои действия списка
Реестр действий тулбара глобальный: registerListAction добавляет действие во все списки, повторная регистрация с тем же key замещает прежнее (в т.ч. встроенное), unregisterListAction(key) убирает. Область действия сужается предикатом criteria (сущность, права); порядок в тулбаре — числом order. Контекст ListActionContext даёт entity/config/rows/selectedIds/selectedRows, операции reload/openCreate/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
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 |