@ecosystem/ui-core (0.31.0)

Published 2026-08-25 09:41:30 +03:00 by qua8ion

Installation

@ecosystem:registry=
npm install @ecosystem/ui-core@0.31.0
"@ecosystem/ui-core": "0.31.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 равно, не равно (значение — мини-пикер: поиск по 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.

Ссылки (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 из БД.

Обратная сторона — виджет «дети по ссылке». Из каждой входящей ссылки ядро выводит виджет children:{ребёнок}.{fkПоле} (компонент EntityChildren, уже зарегистрирован в реестре фронта) и кладёт его в палитру карточки цели — блок ставится человеком в настройке раскладки, как любой виджет. Внутри — обычный ListViewPage во встраиваемом режиме, зашито суженный до детей текущей записи (lockedFilters): фильтры и карточки работают как в полном списке (создание — тоже, но FK новой записи пока заполняется вручную). На форме создания вместо списка заглушка — записи ещё нет. Если один ребёнок ссылается на цель двумя полями, заголовки виджетов различаются полем.

Многие-ко-многим — явная 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 и т.п.) пересобираются кодом на каждый запрос и не устаревают в БД.

Код остаётся источником истины о наборе полей, их типах, обязательности и правах — раскладка говорит только о расположении, подписи и выборе компонента. Расхождение с кодом никогда не ломает карточку и лечится молча: исчезнувшее свойство выпадает из раскладки, новое дописывается в конец последней секции, ставшее обязательным скрытое поле возвращается на карточку, пропавший вариант компонента откатывается к авто-подбору. При сохранении, наоборот, всё проверяется строго (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 — брендовый локап 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.

Планировщик (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 }]

Tailwind

Разметка компонентов — на Tailwind 4. В приложении-потребителе:

  1. Добавьте исходники пакета в сканирование: @source "../node_modules/@ecosystem/ui-core/src"; (или путь до ui/src ядра при file:-установке).
  2. Определите брендовые токены темы, которые использует разметка: --color-brand-500, --color-brand-600, --color-brand-700 (см. @theme Tailwind 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() сообщить хосту, что запись изменена (модалка передаст это списку)
  1. Свойство обязано быть ReadOnly — иначе значение уедет ещё и в патч. Отсюда же следствия: на форме создания поля нет, значение читается из record.
  2. Проп disabled такому редактору всегда приходит true (то же следствие ReadOnly) — доменный редактор его игнорирует и решает интерактивность сам: !preview && canWrite плюс готовность собственных данных.
  3. Контекста может не быть (null) — реестр общий, тот же компонент могут смонтировать вне карточки. Без контекста — только чтение.
  4. В preview мутации запрещены: панель настройки раскладки не должна менять данные (читать справочники можно).
  5. После успешной мутации — await ctx.reload(), затем ctx.notifyChanged(). Без reload() поле покажет старое значение, а следующее «Сохранить» упрётся в ложный конфликт версий — у формы останется устаревший lastUpdateAt.
  6. Имя компонента глобально на весь реестр: называйте по домену (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
Details
npm
2026-08-25 09:41:30 +03:00
0
101 KiB
Assets (1)
Versions (77) View all
0.60.0 2026-09-08
0.59.0 2026-09-06
0.58.1 2026-09-06
0.58.0 2026-09-06
0.57.1 2026-09-06