@ecosystem/ui-core (0.14.0)

Published 2026-08-10 17:55:37 +03:00 by qua8ion

Installation

@ecosystem:registry=
npm install @ecosystem/ui-core@0.14.0
"@ecosystem/ui-core": "0.14.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[] (скрыть действия по ключам).

Тулбар действий. Встроенные действия (ключ/порядок): 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 содержит, пусто, не пусто (значение — один элемент; инпут по виду элемента)

«Пусто/не пусто» (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.

Раскладка карточки (настройка через 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 (одно поле: подпись + редактор из реестра).

Колонки списка

Список настраивается тем же способом и тем же правом: шестерёнка в тулбаре (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.

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.

API доступен и напрямую: settingsApi(scope).list()/viewConfig(type)/get(type)/update(type, patch); анонимная проекция секции с .Public()getPublicSettings(type) (например, для welcome-контента до входа). Типы: SettingsListItem, SettingsViewConfig, SettingsValues, SettingsScope.

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, как на сервере).

Свои действия списка

Реестр действий тулбара глобальный: 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-10 17:55:37 +03:00
4
68 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