@ecosystem/ui-core (0.9.0)
Installation
@ecosystem:registry=npm install @ecosystem/ui-core@0.9.0"@ecosystem/ui-core": "0.9.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, события 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) |
«Пусто/не пусто» (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(...)) при регистрации; хост может дополнительно включить БД-переопределения (таблица EnumDisplays, правится через generic-UI как обычная сущность — значения в params приходят уже с их учётом). icon/color — с Core-Api 0.9.0.
- Ячейка списка (
CellEnum) — бейдж:colorкрасит мягкий фон и текст,icon(короткий текст/эмодзи) рендерится перед заголовком; без цвета — нейтральный серый бейдж. - Редактор карточки (
EditorEnum) — до 4 значений горизонтальная радио-группа, дальше селект; у необязательного поля есть пункт «—» (null), обязательное требует явный выбор (non-nullable enum сервер помечаетrequiredсам). - Фильтр — операторы «равно/не равно» и селект значения (плюс «пусто/не пусто» для nullable).
Режимы загрузки. «Лента» (по умолчанию): порции догружаются при прокрутке (плюс кнопка «Показать ещё»); «Страницы» — классический пейджер. Размер порции/страницы — 25/50/100/200, по умолчанию 100. Режим и размер — глобальная настройка пользователя, localStorage-ключи ecosystem.listview.mode и ecosystem.listview.pageSize.
Логотип (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 с lastUpdateTick (конфликт — 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. В приложении-потребителе:
- Добавьте исходники пакета в сканирование:
@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, как на сервере).
Свои действия списка
Реестр действий тулбара глобальный: 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 |