Расширения с нуля
Эта страница — для .NET-разработчика, который открывает Portal впервые. Здесь нет полного справочника API: только то, как устроены пакеты, откуда в примерах берутся ctx и ct, как клик в браузере доходит до вашего C#, и как запускается задание по расписанию.
Дальше по ссылкам — справочники: выбор типа пакета, WebPart, Timer Job, Event Receiver.
Одна идея на все пакеты
Заголовок раздела «Одна идея на все пакеты»Вы не поднимаете свой сайт и не пишете контроллер ASP.NET. Вы пишете обычный C#-класс, упаковываете DLL в zip и загружаете zip в админку. Portal сам создаёт объект класса и вызывает один метод.
| Что нужно | Класс | Файл пакета | Кто вызывает метод |
|---|---|---|---|
| Блок на странице или своя форма списка | IWebPart | .portalpart | Api, когда браузер открыл страницу или нажал кнопку |
| Код по расписанию | ITimerJob | .portaltimer | Worker, когда наступило время |
| Код после создания, правки или удаления элемента | IEventReceiver | .portalevent | Worker, когда в портале произошло событие |
| Свой POST с JSON на вход и JSON на выход | IWebService | .portalapi | Api, когда кто-то вызвал POST /api/v1/web-services/call/{id} |
«Timer Job», «таймер» и «порталтаймер» — одно и то же. Timer Job — задание, .portaltimer — расширение файла, в котором это задание лежит.
NuGet-пакеты SDK (Portal.WebPart.Sdk и остальные) нужны только чтобы проект скомпилировался. В zip они не входят: те же сборки уже есть в работающем Portal.
Откуда берутся ctx и ct
Заголовок раздела «Откуда берутся ctx и ct»В коротких примерах документации часто написано await ctx.Host.GetListAsync(ct), а объявления ctx и ct выше нет. Это аргументы метода, который Portal вызывает у вашего класса. Их не создают вручную и не достают из статического поля.
ctx (в Timer Job и Event Receiver его чаще называют context) — объект с данными этого вызова: кто вошёл, какая страница, какие настройки, API списков.
ct — сокращение от CancellationToken. Это стандартный тип .NET. Portal передаёт его последним аргументом. Если пользователь закрыл вкладку или Worker остановил задание, токен отменяется. Его прокидывают в каждый await к API портала, чтобы запрос к базе тоже остановился. Имя в сигнатуре может быть ct или cancellationToken — это один и тот же параметр, имя выбираете вы.
Сигнатуры, которые вы реализуете:
// WebPart — отрисовка HTMLTask<WebPartResult> RenderAsync(IWebPartContext ctx, CancellationToken ct);
// WebPart — клик по кнопке с data-wp-actionTask<WebPartResult> HandleActionAsync( string action, JsonElement data, IWebPartContext ctx, CancellationToken ct);
// Timer Job (.portaltimer) — срабатывание по расписаниюTask<object?> ExecuteAsync(TimerJobContext context, ITimerJobApi api, CancellationToken ct);
// Event Receiver — реакция на событие спискаTask<object?> HandleAsync(EventContext context, IReceiverApi api, CancellationToken ct);Фрагмент внутри такого метода выглядит так:
public async Task<WebPartResult> RenderAsync(IWebPartContext ctx, CancellationToken ct){ // ctx и ct уже объявлены строкой выше — это параметры. var item = await ctx.Host.GetOrNewListItemAsync(ct); var title = item.GetString("title") ?? ""; return new WebPartResult($"<p>{System.Net.WebUtility.HtmlEncode(title)}</p>");}WebPart: блок на странице
Заголовок раздела «WebPart: блок на странице»WebPart — кусок HTML, который ваш C# собирает на сервере. Браузер этот C# не выполняет. Браузер получает готовую строку HTML и вставляет её в страницу.
Шаг 1. Создать проект
Заголовок раздела «Шаг 1. Создать проект»Скачайте SDK той же версии, что установленный Portal: korport.ru/developers. Как подключить NuGet: Extension SDK.
dotnet new install ./portal-sdk-1.0.0/templates/portal-webpartdotnet new portal-webpart -n MyWidget -o ./my-widgetКоманда -n MyWidget заменяет в шаблоне заготовку MyWebPart на MyWidget. Получаются класс Contoso.MyWidget.MyWidgetHandler, файл MyWidgetHandler.cs и DLL dist/MyWidget.dll. Поле id шаблон не переименовывает: в manifest.json остаётся contoso.my-webpart. Примеры ниже используют эти имена. Если переименуете класс, namespace или id, те же строки поправьте в манифесте и, когда появится свой JS, в PortalWebPartClients. Иначе пакет установится, а блок на странице не загрузится.
Обычно правите три файла:
| Файл | Зачем |
|---|---|
manifest.json | Ключ в каталоге (id), имя класса (entryType), путь к DLL (entry), настройки, которые увидит редактор страницы (properties) |
MyWidgetHandler.cs | Класс IWebPart: два метода, RenderAsync и HandleActionAsync |
Templates/widget.html | Каркас HTML. C# подставляет в него значения. Можно обойтись и без файла и собирать HTML строкой в коде |
Шаг 2. Что происходит, когда пользователь открывает страницу
Заголовок раздела «Шаг 2. Что происходит, когда пользователь открывает страницу»На странице портала уже есть оболочка csharpWebPartShell.js. Её писать не нужно. Она делает следующее.
Пользователь открыл страницу │ ▼Оболочка шлёт POST /api/v1/webparts/invoke/{id}/render в теле: properties (настройки блока) и host (страница или форма списка) │ ▼Portal находит ваш класс по id из manifest.jsonи вызывает RenderAsync(ctx, ct) │ ▼Вы возвращаете WebPartResult с полем Html — обычная строка │ ▼Оболочка делает container.innerHTML = htmlзатем сама вешает обработчики на кнопки с data-wp-actionи, если в пакете есть свой JS, вызывает вашу функцию bindТекст, который пришёл от пользователя или из списка, пропускайте через System.Net.WebUtility.HtmlEncode, прежде чем вставлять в HTML.
Шаг 3. Первый вариант: страница только показывает данные
Заголовок раздела «Шаг 3. Первый вариант: страница только показывает данные»RenderAsync вызывается при каждой загрузке блока. HandleActionAsync в этом варианте можно оставить пустым — шаблон так и делает.
using Portal.WebPart.Sdk;
namespace Contoso.MyWidget;
public sealed class MyWidgetHandler : IWebPart{ public Task<WebPartResult> RenderAsync(IWebPartContext ctx, CancellationToken ct) { var title = ctx.Properties.GetString("title", "Мой блок"); var who = ctx.User?.DisplayName ?? ctx.User?.Login ?? "гость";
var html = $""" <div class="my-webpart"> <h3>{System.Net.WebUtility.HtmlEncode(title)}</h3> <p>Вы вошли как {System.Net.WebUtility.HtmlEncode(who)}</p> </div> """;
return Task.FromResult(new WebPartResult(html)); }
public Task<WebPartResult> HandleActionAsync( string action, System.Text.Json.JsonElement data, IWebPartContext ctx, CancellationToken ct) { return Task.FromResult(new WebPartResult("", Error: "Действий нет")); }}ctx.Properties — то, что редактор страницы сохранил в панели свойств. Поле title объявляется в manifest.json:
{ "id": "contoso.my-webpart", "title": "My WebPart", "version": "1.0.0", "runtime": "dotnet", "entry": "dist/MyWidget.dll", "entryType": "Contoso.MyWidget.MyWidgetHandler", "properties": { "title": { "type": "text", "label": "Заголовок", "default": "Мой блок" } }}Ключи properties и строки в GetString("title", ...) совпадают. Виды полей панели (список, число, флажок): Параметры WebPart.
Шаг 4. Кнопка: клик обрабатывается на сервере
Заголовок раздела «Шаг 4. Кнопка: клик обрабатывается на сервере»Свой fetch для кнопки писать не нужно. Достаточно атрибута data-wp-action на кнопке. Оболочка страницы сама:
- Слушает клик (у
input type="date"иselect— событиеchange). - Собирает объект
data. - Шлёт
POST /api/v1/webparts/invoke/{id}/action. - Подставляет в блок новый HTML, который вернул
HandleActionAsync.
Откуда берётся data:
| Что написано в HTML | Что приходит в C# | Как прочитать |
|---|---|---|
data-wp-action="show" | аргумент action равен "show" | if (action == "show") |
data-wp-room-id="12" | поле wpRoomId | data.AsActionData().GetString("roomId") |
<input name="note"> внутри <form> | поле wpNote | form.GetString("note") |
Имя note оболочка превращает в wpNote (body_html → wpBodyHtml). AsActionData().GetString("note") находит и wpNote, и note. Явные data-wp-* на кнопке важнее одноимённых полей формы.
Полный круг: форма, кнопка, ответ сервера. ct в этом примере в запросы не уходит — запросов к спискам ещё нет. Параметр в сигнатуре всё равно обязателен: его передаёт Portal.
using System.Net;using System.Text.Json;using Portal.WebPart.Sdk;
namespace Contoso.MyWidget;
public sealed class MyWidgetHandler : IWebPart{ public Task<WebPartResult> RenderAsync(IWebPartContext ctx, CancellationToken ct) => Task.FromResult(Build(ctx, echoed: null));
public Task<WebPartResult> HandleActionAsync( string action, JsonElement data, IWebPartContext ctx, CancellationToken ct) { if (action != "show") return Task.FromResult(new WebPartResult("", Error: "Неизвестное действие"));
var form = data.AsActionData(); var note = form.GetString("note"); var echoed = string.IsNullOrWhiteSpace(note) ? "Пусто" : $"{note} — сервер ответил в {DateTime.Now:HH:mm:ss}";
return Task.FromResult(Build(ctx, echoed)); }
private static WebPartResult Build(IWebPartContext ctx, string? echoed) { var title = WebUtility.HtmlEncode(ctx.Properties.GetString("title", "Мой блок")); var message = echoed is null ? "" : $"""<p class="form-success">{WebUtility.HtmlEncode(echoed)}</p>""";
var html = $""" <form class="my-webpart"> <h3>{title}</h3> <label>Заметка <input class="tbx__control" name="note" type="text"> </label> {message} <button type="button" class="btn btn--primary" data-wp-action="show"> Отправить на сервер </button> </form> """;
return new WebPartResult(html); }}После клика блок рисуется заново целиком. Отдельного хранилища состояния в браузере нет: всё, что должно пережить клик, либо лежит в списках портала, либо снова попадает в HTML, который собрал HandleActionAsync.
Классы btn, tbx__control, form-success — стили портала, их подключать в пакет не нужно. Свой внешний вид — файл assets/main.css и поле styles в манифесте. Справочник классов: Стили PortalUI.
Шаг 5. Зачем тогда свой JavaScript
Заголовок раздела «Шаг 5. Зачем тогда свой JavaScript»Файл assets/main.js (в манифесте — "scripts": ["dist/main.js"]) выполняется в браузере после того, как сервер уже отдал HTML. Он нужен для поведения, которому сервер не требуется: фильтр по уже показанным карточкам, раскрытие блока, локальная модалка без записи.
Клики по data-wp-action этот файл не перехватывает. Их обрабатывает оболочка портала и вызывает ваш C#.
Портал после каждой отрисовки ищет window.PortalWebPartClients["ваш id из manifest.json"] и вызывает bind(container). container — DOM-узел блока.
(function () { const MANIFEST_KEY = 'contoso.my-webpart'; // поле "id" в manifest.json, не имя класса
function bind(root) { if (!root || root.dataset.uiBound === '1') return; root.dataset.uiBound = '1';
const input = root.querySelector('.my-webpart__filter'); input?.addEventListener('input', () => { const q = input.value.trim().toLowerCase(); root.querySelectorAll('.my-webpart__row').forEach((row) => { const text = row.textContent.toLowerCase(); row.hidden = q.length > 0 && !text.includes(q); }); }); }
window.PortalWebPartClients = window.PortalWebPartClients || {}; window.PortalWebPartClients[MANIFEST_KEY] = { bind };})();dataset.uiBound нужен потому, что bind вызывается после каждого ответа сервера. HTML при этом новый, старые слушатели умирают вместе со старым DOM, и флаг ставится заново.
Данные для такого фильтра сервер кладёт прямо в HTML (например, в <script type="application/json">). Отдельный запрос из main.js к спискам для этого не обязателен. Если JavaScript всё же ходит в REST сам, проверку прав это не заменяет: запись и чтение, от которых зависит доступ, делайте в RenderAsync / HandleActionAsync.
Разметка, автосбор формы, пикеры человека и подстановки: Интерфейс, контролы и API.
Шаг 6. Прочитать элемент списка
Заголовок раздела «Шаг 6. Прочитать элемент списка»На форме списка (/items/12/edit, создание, просмотр) Portal сам знает список и номер элемента. Они лежат в ctx.Host. В панели свойств номер указывать не нужно.
public async Task<WebPartResult> RenderAsync(IWebPartContext ctx, CancellationToken ct){ if (!ctx.Host.IsListForm || !ctx.Host.HasList) return new WebPartResult("", Error: "Поставьте блок на форму списка");
// ct — тот же параметр метода. Edit/view загружает элемент, new возвращает пустой. var item = await ctx.Host.GetOrNewListItemAsync(ct); var title = item.GetString("title") ?? "";
var html = $""" <p>{System.Net.WebUtility.HtmlEncode(title)}</p> """; return new WebPartResult(html);}Ключ "title" — внутреннее имя поля списка (internal_name), не GUID. Запись обратно: item["title"] = "Новое"; и await item.CreateAsync(ct) или await item.UpdateAsync(ct). Все типы полей, форма и кнопка «Сохранить»: Кастомная форма.
На обычной странице списка в ctx.Host нет. Идентификатор списка берут из панели свойств (listPicker) и читают так:
var listId = ctx.Properties.GetGuid("listId");var items = await ctx.Lists.QueryListItemsAsync( listId, ListItemQueryBuilder.ForList(listId).Take(20).Build(), ct);Нужен using Portal.Contracts.Lists;. Фильтры и сортировка: Выборка элементов.
Шаг 7. Собрать пакет и поставить на страницу
Заголовок раздела «Шаг 7. Собрать пакет и поставить на страницу»./portal-sdk-1.0.0/scripts/pack-portalpart.sh ./my-widget# → my-widget/dist/my-widget.portalpartWindows: .\portal-sdk-1.0.0\scripts\pack-portalpart.ps1 .\my-widget
- Админка → WebPart — загрузите
.portalpart. - Откройте страницу, Редактировать, в колонке + WebPart, выберите свой блок.
- В панели справа заполните свойства из
manifest.jsonи сохраните страницу.
Перезапуск Api не нужен. После правки кода увеличьте version в манифесте, соберите пакет ещё раз и загрузите его снова с тем же id. Подробности сборки: Сборка .portalpart.
Timer Job: файл .portaltimer
Заголовок раздела «Timer Job: файл .portaltimer»Timer Job — тот же приём «класс + zip», только без страницы и без браузера. Метод ExecuteAsync вызывает процесс Worker, когда наступает время расписания.
У задания нет «текущего пользователя, который кликнул». Чтение и запись списков идут от системной учётной записи portal-system (администратор портала). Идентификатор списка в ctx.Host не появляется: его кладут в настройки экземпляра задания.
Пакет и экземпляр — разные вещи.
Пакет .portaltimer | Экземпляр задания | |
|---|---|---|
| Что это | Ваша DLL и описание полей настроек | «Запускай этот класс каждый день в 09:00 с такими настройками» |
| Сколько | Один на версию кода | Сколько угодно на один пакет: разное время и разные настройки |
| Где создать | Админка → Timer Jobs → Пакеты | Админка → Timer Jobs → Задания по расписанию |
Служба timer-extensions (Админка → Службы) — выключатель очереди. Пока она включена (так и есть после установки пакета), расписание живёт у экземпляра, не у строки службы.
Шаг 1. Создать проект
Заголовок раздела «Шаг 1. Создать проект»dotnet new install ./portal-sdk-1.0.0/templates/portal-timerjobdotnet new portal-timerjob -n Heartbeat -o ./heartbeat-n Heartbeat подставляет имя в класс и DLL: Contoso.Heartbeat.HeartbeatHandler, файл HeartbeatHandler.cs, dist/Heartbeat.dll. Поле id шаблон оставляет как contoso.my-timerjob — его можно сменить, например на contoso.heartbeat. Worker ищет класс по entryType, а тип задания в очереди равен id.
В manifest.json блок configSchema — поля, которые админ заполнит у экземпляра. Значения приходят в context.Config как JSON.
{ "id": "contoso.heartbeat", "title": "Проверка списка", "version": "1.0.0", "description": "Пишет в лог Worker заголовок первого элемента списка", "entry": "dist/Heartbeat.dll", "entryType": "Contoso.Heartbeat.HeartbeatHandler", "serviceKey": "timer-extensions", "configSchema": { "message": { "type": "text", "label": "Сообщение в лог" }, "sampleListId": { "type": "text", "label": "GUID списка" } }}Шаг 2. Метод, который вызывает Worker
Заголовок раздела «Шаг 2. Метод, который вызывает Worker»context — этот запуск: id экземпляра, время, JSON настроек. api — лог, списки, пользователи. ct — отмена, её передают в QueryListItemsAsync.
using System.Text.Json;using Portal.Contracts.Lists;using Portal.TimerJob.Sdk;
namespace Contoso.Heartbeat;
public sealed class HeartbeatHandler : ITimerJob{ public async Task<object?> ExecuteAsync( TimerJobContext context, ITimerJobApi api, CancellationToken ct) { var message = ReadString(context.Config, "message") ?? "heartbeat"; api.Log.Info("Heartbeat:", message, "job=", context.JobType);
var listText = ReadString(context.Config, "sampleListId"); if (!Guid.TryParse(listText, out var listId) || listId == Guid.Empty) { api.Log.Warn("В настройках задания нет GUID списка sampleListId"); return new { ok = true, message, sampleTitle = (string?)null }; }
var items = await api.Lists.QueryListItemsAsync( listId, ListItemQueryBuilder.ForList(listId).Take(1).Build(), ct);
var sampleTitle = items.Count > 0 ? items[0].GetString("title") : null; api.Log.Info("Первый заголовок:", sampleTitle ?? "(пусто)"); return new { ok = true, message, sampleTitle }; }
private static string? ReadString(JsonElement config, string name) { if (config.ValueKind != JsonValueKind.Object) return null; if (!config.TryGetProperty(name, out var el)) return null; return el.ValueKind == JsonValueKind.String ? el.GetString() : null; }}Объект в return Portal сохраняет как результат задания. На ход расписания он не влияет: важно не бросить исключение. Если метод бросил исключение, экземпляр в Админка → Задания будет со статусом failed и текстом ошибки.
Строки api.Log.Info попадают в stdout процесса Worker с префиксом [timer-job]. При Docker: docker compose logs -f worker.
Шаг 3. Как Worker доходит до вашего метода
Заголовок раздела «Шаг 3. Как Worker доходит до вашего метода»Админ включил экземпляр: «ежедневно в 09:00» │ ▼CronSchedulerWorker примерно раз в минуту смотрит расписаниеЕсли время пришло — кладёт запись в очередь portal_jobs │ ▼JobQueueWorker забирает запись и по id пакета грузит вашу DLL │ ▼ExecuteAsync(context, api, ct)Кнопка Запуск у экземпляра пропускает ожидание cron и сразу ставит ту же запись в очередь (manual: true). Код ExecuteAsync от этого не меняется.
Расписание в админке задаётся словами: каждую минуту, каждые N минут, ежедневно, по дням недели, ежемесячно. Внутри Portal хранит cron. Время — локальные часы машины, где работает Worker. Типы и поля: Runtime Timer Jobs.
Шаг 4. Собрать и включить
Заголовок раздела «Шаг 4. Собрать и включить»./portal-sdk-1.0.0/scripts/pack-portaltimer.sh ./heartbeat# → heartbeat/dist/heartbeat.portaltimerWindows: .\portal-sdk-1.0.0\scripts\pack-portaltimer.ps1 .\heartbeat
- Админка → Timer Jobs → Пакеты — загрузите
.portaltimer. В каталоге обработчиков появится класс. - Задания по расписанию — создайте экземпляр: выберите обработчик, тип расписания (для проверки удобно «каждую минуту»), заполните поля из
configSchema, включите задание. - Нажмите Запуск, чтобы не ждать расписание.
- Откройте Админка → Задания и найдите запись:
completedилиfailed.
Проверка, что служба не выключена: Админка → Службы, строка пользовательских заданий по расписанию (timer-extensions).
После изменения кода увеличьте version, соберите zip и загрузите пакет с тем же id. Worker подхватит DLL без перезапуска. Сборка и поля манифеста: Сборка .portaltimer, манифест, SDK.
Event Receiver: реакция на элемент списка
Заголовок раздела «Event Receiver: реакция на элемент списка»Тот же Worker и та же очередь, но повод другой: кто-то создал, изменил или удалил элемент. Браузера нет. Метод один — HandleAsync.
using Portal.EventReceiver.Sdk;
namespace Contoso.MyEventReceiver;
public sealed class MyEventReceiverHandler : IEventReceiver{ public async Task<object?> HandleAsync(EventContext context, IReceiverApi api, CancellationToken ct) { if (!context.IsListItemEvent()) return new { ok = true, skipped = true };
// Для deleted элемент уже удалён — берите снимок: GetListItemSnapshotAsync. var item = await context.GetListItemAsync(api, ct); api.Log.Info(context.EventName, "title=", item?.GetString("title")); return new { ok = true }; }}context здесь — событие (имя, список, номер элемента, пользователь, который его вызвал). api — списки и лог, как у таймера. ct снова последний аргумент Portal.
Сборка: dotnet new portal-eventreceiver, затем pack-portalevent.sh / .ps1. Файл .portalevent загружается в Админка → Event Receivers. Потом привязку вешают на событие: карточка списка → Обработчики, либо + Receiver в той же админке. Пока привязки нет, класс установлен, но не вызывается. Сценарии (поля, права, письма): Event Receivers.
Что выбрать
Заголовок раздела «Что выбрать»| Ситуация | Пакет |
|---|---|
| Пользователь должен увидеть блок или форму | WebPart, .portalpart |
| Нажали кнопку, и нужно записать данные с проверкой прав вошедшего | HandleActionAsync в том же WebPart |
| На странице только спрятать или отфильтровать уже показанное | main.js, функция bind |
| Каждый день / каждый час, без участия пользователя | Timer Job, .portaltimer |
| Сразу после сохранения элемента списка | Event Receiver, .portalevent |
| Внешняя система присылает JSON и ждёт JSON | Веб-сервис, .portalapi — Веб-сервисы |
Несколько таких пакетов вместе с узлами и списками поставляют одним модулем .portalmod: Функциональные модули.
Отладка с точкой останова: Debug-сборка с PDB и Attach к Api (WebPart, веб-сервис) или к Worker (Timer Job, Event Receiver) — Отладка расширений.