Разметка WebPart на Razor
Пошаговое руководство для разработчика, который пишет вебчасть Portal и раньше не работал с Razor. Если вы собирали HTML строками в C# или через {{ключ}} в Templates/*.html, этот файл — новый основной способ. Старый способ остаётся и описан в конце.
Связанные страницы: стили кнопок и полей, что делает data-wp-action, свойства Property Pane, сборка .portalpart.
Что такое Razor в вебчасти
Заголовок раздела «Что такое Razor в вебчасти»Razor — это HTML-файл с расширением .cshtml, в котором в нужных местах стоит C#.
- Обычные теги пишутся как в HTML. Редактор подсвечивает разметку, кавычки в атрибутах экранировать не нужно.
- Выражение
@Model.Titleподставляет значение и само кодирует его:<станет<. ОтдельныйHtmlEncodeдля таких мест не нужен. - Условия и циклы — обычные
@ifи@foreach. - Файл компилируется вместе с C# при
dotnet publishи попадает в ту же DLL. Отдельный шаблонизатор на сервере не запускается. - Браузер по-прежнему получает готовую строку HTML. Razor выполняется на сервере, внутри
RenderAsync.
Вебчасть по-прежнему реализует IWebPart. Клик по кнопке с data-wp-action по-прежнему вызывает HandleActionAsync. Меняется только то, где лежит разметка.
Уже установленные модули (задачи, отпуска, ITSM и остальные пакеты на StringBuilder или WebPartTemplate) этот способ не заменяет. Их исходники и собранные .portalpart работают как раньше. Razor подключается только в проекте, где есть .cshtml.
1. Создать проект
Заголовок раздела «1. Создать проект»Нужен Extension SDK той же версии, что установленный Portal: korport.ru/developers.
dotnet new install ./portal-sdk-1.0.0/templates/portal-webpartdotnet new portal-webpart -n MyWidget -o ./my-widgetcd ./my-widgetКоманда -n MyWidget заменяет в шаблоне заготовку MyWebPart на MyWidget. Получаются:
| Файл | Зачем |
|---|---|
MyWidgetHandler.cs | Класс IWebPart: данные и действия |
Views/Widget.cshtml | Разметка страницы |
Views/_ViewImports.cshtml | Общие директивы Razor для всех представлений проекта |
MyWidget.csproj | Проект Microsoft.NET.Sdk.Razor. Обычно не трогают |
manifest.json | Ключ в каталоге, имя класса, свойства Property Pane |
Поле id в manifest.json шаблон не переименовывает: там остаётся contoso.my-webpart. Его стоит сменить на свой ключ, например contoso.my-widget, и тот же ключ потом использовать в JS, если он появится.
Сборка и установка:
./portal-sdk-1.0.0/scripts/pack-portalpart.sh ./my-widget.\portal-sdk-1.0.0\scripts\pack-portalpart.ps1 .\my-widgetФайл dist/my-widget.portalpart загружается в Админка → WebPart. После правки кода увеличьте version в manifest.json, соберите пакет снова и загрузите его с тем же id. Перезапуск API не нужен.
Проверьте заготовку до своих правок: поставьте вебчасть на страницу. Должен появиться заголовок из Property Pane и два пункта списка.
2. Два файла: модель и разметка
Заголовок раздела «2. Два файла: модель и разметка»C# готовит объект. Razor этот объект рисует.
MyWidgetHandler.cs:
public async Task<WebPartResult> RenderAsync(IWebPartContext ctx, CancellationToken ct){ var model = new MyWidgetModel { Title = ctx.Properties.GetString("title", "Мой виджет"), Items = ["Первый", "Второй"], }; var html = await WebPartRazor.RenderAsync("Views/Widget.cshtml", model); return new WebPartResult(html);}WebPartRazor.RenderAsync без первого аргумента-сборки ищет .cshtml в той DLL, из которой его вызвали. Путь Views/Widget.cshtml и короткое имя Widget означают одно и то же представление.
Views/Widget.cshtml из шаблона:
@model Contoso.MyWidget.MyWidgetModel<div class="my-webpart"> <h3 class="my-webpart__title">@Model.Title</h3> @if (Model.Items.Count == 0) { <p class="webpart-empty">Нет элементов</p> } else { <ul> @foreach (var item in Model.Items) { <li>@item</li> } </ul> }</div>Строка @model объявляет тип объекта, который вы передали в RenderAsync. После неё в файле доступен Model.
Класс модели лежит рядом с обработчиком. Свойства должны быть доступны Razor: public, с get.
public sealed class MyWidgetModel{ public required string Title { get; init; } public required IReadOnlyList<string> Items { get; init; }}Имя класса после dotnet new -n MyWidget будет MyWidgetModel, пространство имён — Contoso.MyWidget. В @model пишите то же полное имя, что у класса в C#.
3. Синтаксис Razor, которого хватает для вебчасти
Заголовок раздела «3. Синтаксис Razor, которого хватает для вебчасти»Дальше — только те конструкции, которые нужны в разметке Portal. Остальной Razor (layouts, tag helpers MVC, @inject) вебчасти не используют.
Текст и атрибуты
Заголовок раздела «Текст и атрибуты»<h3>@Model.Title</h3><input class="tbx__control" value="@Model.Search">@Model.Title кодируется и в тексте, и в атрибуте. Если в заголовке окажется <script>, на страницу попадёт текст, а не тег.
Несколько свойств подряд пишут в скобках, иначе Razor не поймёт, где выражение кончается:
<p>Найдено: @(Model.Items.Count)</p><button class="btn btn--settings btn--sm @(Model.Scope == "mine" ? "is-active" : null)">Мои</button>Символ @ в тексте письма или адресе удваивают: support@@contoso.ru.
Условие
Заголовок раздела «Условие»@if (!string.IsNullOrEmpty(Model.FormError)){ <p class="form-error">@Model.FormError</p>}else if (Model.Items.Count == 0){ <p class="webpart-empty">Заявок нет</p>}Фигурные скобки обязательны. Внутри них снова HTML.
<select class="tbx__control" name="status"> <option value="">Все статусы</option> @foreach (var item in Model.Statuses) { <option value="@item.Value" selected="@(item.Value == Model.Status)">@item.Label</option> }</select>selected="@(условие)" — приём Razor для логического атрибута. Если условие истинно, в HTML будет selected="selected". Если ложно, атрибут не попадёт в разметку. То же работает для checked и disabled.
Комментарий
Заголовок раздела «Комментарий»@* это комментарий Razor, в HTML его не будет *@<!-- комментарий --> попадёт в HTML, который увидит браузер.
Готовый HTML: @Html.Raw
Заголовок раздела «Готовый HTML: @Html.Raw»Почти всё кодируется. Исключение — фрагмент, который уже является HTML или JSON и должен остаться как есть. Типичный случай — значение пикера сотрудника внутри <script type="application/json">: кавычки JSON нельзя превращать в ".
<script type="application/json" data-portal-person-value>@Html.Raw(Model.AssigneeJson)</script>AssigneeJson должен быть JSON, который собрал ваш C# (JsonSerializer), а не текст, введённый пользователем в поле. Пользовательский текст в @Html.Raw не передают.
Второй файл разметки
Заголовок раздела «Второй файл разметки»Форму, карточку или строку таблицы выносят в соседний .cshtml. В Views/_ViewImports.cshtml шаблон уже подключает один тег:
@addTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.PartialTagHelper, Microsoft.AspNetCore.Mvc.TagHelpersЭтой строки достаточно. Не заменяйте её на @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers. Полный набор тегов MVC перехватывает обычные <form>, <input> и <a> и начинает вести себя как форма ASP.NET, а не как контрол портала.
Вставка:
@if (Model.ShowCreate){ <partial name="CreateForm" model="Model" />}name — имя файла без .cshtml. Файл Views/CreateForm.cshtml начинается со своего @model и получает тот же объект.
4. Контролы на странице
Заголовок раздела «4. Контролы на странице»Классы кнопок и полей — из PortalUI, чтобы виджет выглядел как остальной портал. Полный набор: Стили и контролы. Здесь — те, с которых начинают.
Корневой блок называйте своим префиксом, например webpart-requests, и дальше используйте его в классах. Так стили не заденут чужие вебчасти.
| Элемент | Класс | Заметка |
|---|---|---|
| Главная кнопка | btn btn--primary | «Создать», «Сохранить» |
| Отмена | btn btn--cancel | Рядом с главной кнопкой, в диалоге |
| Кнопка тулбара | btn btn--settings btn--sm | «Обновить», переключатели |
| Поле, список, многострочный текст | tbx__control | На <input>, <select>, <textarea> |
| Ошибка | form-error | Текст ошибки из модели |
| Пусто | webpart-empty | Когда список пуст |
| Подсказка | webpart-hint | Например «укажите список в настройках» |
Иконка Font Awesome и подпись кнопки:
<button type="button" class="btn btn--primary btn--sm" data-wp-action="openCreate"> <i class="fa-solid fa-plus" aria-hidden="true"></i> <span class="btn__text">Создать</span></button>Поле:
<label>Тема <input type="text" class="tbx__control" name="title" required value="@Model.Draft.Title"></label>Атрибут name нужен, если значение должно уехать на сервер вместе с нажатием кнопки. Как это устроено — в следующем разделе.
5. Кнопка, которая вызывает сервер
Заголовок раздела «5. Кнопка, которая вызывает сервер»Оболочка страницы сама слушает элементы с data-wp-action. Свой fetch и свой click в main.js для такой кнопки не пишут.
Цепочка:
- В
.cshtmlу кнопки стоитdata-wp-action="имя". - Пользователь нажимает кнопку (для
input type="date"иselectс тем же атрибутом срабатывает смена значения). - Браузер шлёт
POST /api/v1/webparts/invoke/{id}/action. - Portal вызывает
HandleActionAsync. - Вы возвращаете новый HTML. Контейнер вебчасти заменяется целиком.
Имя в data-wp-action становится аргументом action. Дополнительные data-wp-* попадают в data.
<button type="button" class="btn btn--settings btn--sm" data-wp-action="open" data-wp-id="@item.Id">@item.Title</button>В C#:
public async Task<WebPartResult> HandleActionAsync( string action, JsonElement data, IWebPartContext ctx, CancellationToken ct){ var form = data.AsActionData(); if (action == "open") { var id = form.GetString("id"); return await RenderOpenedAsync(ctx, id, ct); }
return new WebPartResult("", Error: "Неизвестное действие");}data-wp-id читается как form.GetString("id"). Регистр и snake_case SDK приводит к одному имени: data-wp-room-id, roomId и room_id — одно поле. Методы: GetString, GetBoolean, GetGuid, GetInt32, GetJson.
После действия снова вызывайте тот же рендер, что и в RenderAsync. Отдельного хранилища состояния в браузере нет: открытая форма, текст ошибки и фильтр должны оказаться в модели, из которой Razor рисует страницу заново.
6. Пример: фильтр и создание заявки
Заголовок раздела «6. Пример: фильтр и создание заявки»Ниже связка, которую можно перенести в свой проект. Список статусов и строк рисует Razor. Кнопки только называют действие.
public sealed class RequestsModel{ public string Search { get; init; } = ""; public string Status { get; init; } = ""; public bool ShowCreate { get; init; } public string FormError { get; init; } = ""; public string DraftTitle { get; init; } = ""; public IReadOnlyList<StatusChoice> Statuses { get; init; } = []; public IReadOnlyList<RequestRow> Items { get; init; } = [];}
public sealed class StatusChoice{ public required string Value { get; init; } public required string Label { get; init; }}
public sealed class RequestRow{ public required string Id { get; init; } public required string Number { get; init; } public required string Title { get; init; } public required string Status { get; init; }}Views/List.cshtml
Заголовок раздела «Views/List.cshtml»@model Contoso.MyWidget.RequestsModel<div class="webpart-requests"> <div class="webpart-requests__toolbar"> <input type="search" class="tbx__control" name="search" placeholder="Поиск" value="@Model.Search"> <select class="tbx__control" name="status"> <option value="">Все статусы</option> @foreach (var item in Model.Statuses) { <option value="@item.Value" selected="@(item.Value == Model.Status)">@item.Label</option> } </select> <button type="button" class="btn btn--settings btn--sm" data-wp-action="filter">Найти</button> <button type="button" class="btn btn--primary btn--sm" data-wp-action="openCreate"> <span class="btn__text">Создать</span> </button> </div>
@if (!string.IsNullOrEmpty(Model.FormError)) { <p class="form-error">@Model.FormError</p> }
@if (Model.Items.Count == 0) { <p class="webpart-empty">Заявок нет</p> } else { <table class="tbl"> <thead> <tr><th>Номер</th><th>Тема</th><th>Статус</th></tr> </thead> <tbody> @foreach (var item in Model.Items) { <tr> <td>@item.Number</td> <td> <button type="button" class="btn btn--settings btn--sm" data-wp-action="open" data-wp-id="@item.Id">@item.Title</button> </td> <td>@item.Status</td> </tr> } </tbody> </table> }
@if (Model.ShowCreate) { <partial name="CreateForm" model="Model" /> }</div>Кнопка «Найти» лежит рядом с полями, но не внутри <form>. Тогда оболочка ищет поля у родителя кнопки и забирает name="search" и name="status". На сервере это form.GetString("search") и form.GetString("status").
Надёжнее обернуть тулбар в <form onsubmit="return false;"> или в <div data-wp-collect>. Оболочка берёт поля из ближайшего <form>, иначе из [data-wp-collect], иначе от родителя кнопки. Явный data-wp-* на самой кнопке важнее одноимённого поля. Участок, который собирать не нужно, помечают data-wp-no-collect.
Views/CreateForm.cshtml
Заголовок раздела «Views/CreateForm.cshtml»@model Contoso.MyWidget.RequestsModel<form class="webpart-requests__dialog" onsubmit="return false;"> <h3>Новая заявка</h3> <label>Тема <input type="text" class="tbx__control" name="title" required value="@Model.DraftTitle"> </label> <div class="webpart-requests__dialog-actions"> <button type="button" class="btn btn--primary" data-wp-action="create">Создать</button> <button type="button" class="btn btn--cancel" data-wp-action="cancelCreate">Отмена</button> </div></form>type="button", не submit. Отправку делает data-wp-action, а не обычный submit формы. onsubmit="return false;" гасит случайную отправку по Enter.
Поля этой формы (name="title") уедут вместе с «Создать», потому что кнопка внутри <form>. Поля фильтра снаружи этой формы в это действие не попадают.
Обработчик
Заголовок раздела «Обработчик»public async Task<WebPartResult> HandleActionAsync( string action, JsonElement data, IWebPartContext ctx, CancellationToken ct){ var form = data.AsActionData(); return action switch { "filter" => await RenderAsync(ctx, form.GetString("search"), form.GetString("status"), showCreate: false, formError: "", draftTitle: "", ct), "openCreate" => await RenderAsync(ctx, form.GetString("search"), form.GetString("status"), showCreate: true, formError: "", draftTitle: "", ct), "cancelCreate" => await RenderAsync(ctx, form.GetString("search"), form.GetString("status"), showCreate: false, formError: "", draftTitle: "", ct), "create" => await CreateAsync(ctx, form, ct), "open" => await OpenAsync(ctx, form.GetString("id"), ct), _ => new WebPartResult("", Error: "Неизвестное действие"), };}
private async Task<WebPartResult> CreateAsync(IWebPartContext ctx, WebPartActionData form, CancellationToken ct){ var title = form.GetString("title").Trim(); if (title.Length == 0) { return await RenderAsync(ctx, search: "", status: "", showCreate: true, formError: "Укажите тему", draftTitle: "", ct); }
await ctx.Lists.CreateItemAsync(ctx.Properties.GetGuid("listId"), new Dictionary<string, object?> { ["title"] = title, }, ct);
return await RenderAsync(ctx, search: "", status: "", showCreate: false, formError: "", draftTitle: "", ct);}RenderAsync в этом примере собирает RequestsModel и вызывает WebPartRazor.RenderAsync. Ошибка «Укажите тему» — обычная строка модели. В .cshtml она попадает в <p class="form-error"> и кодируется сама.
Флажок читается так: form.GetBoolean("notify"), если в разметке <input type="checkbox" name="notify">. Снятый флажок приходит как ложь.
7. Свой CSS и небольшой JS
Заголовок раздела «7. Свой CSS и небольшой JS»Оформление, которого нет среди классов портала, кладут в assets/main.css. В manifest.json после сборки pack-portalpart сам пропишет styles. В CSS используйте префикс корневого блока (.webpart-requests), цвета — через var(--color-surface) и соседние переменные темы.
JavaScript нужен, когда жест не должен ходить на сервер: локальный поиск по уже нарисованным строкам, раскрытие блока. Файл assets/main.js регистрирует bind. Оболочка вызывает его после каждого серверного рендера.
(function () { const KEY = 'contoso.my-widget';
function bind(root) { if (!root || root.dataset.uiBound === '1') return; root.dataset.uiBound = '1'; }
window.PortalWebPartClients = window.PortalWebPartClients || {}; window.PortalWebPartClients[KEY] = { bind };})();KEY совпадает с id в manifest.json. Кнопки с data-wp-action в bind не вешают: их обрабатывает оболочка.
В .cshtml не кладут <script> с логикой страницы. Такой скрипт выполнится как HTML, который вставили в контейнер, и его легко продублировать при следующем рендере.
8. Частые ошибки
Заголовок раздела «8. Частые ошибки»| Что видите | Почему |
|---|---|
Представление не найдено, в тексте перечислены другие .cshtml | Путь в RenderAsync не совпал с файлом. Проверьте Views/ и имя |
| Имя представления неоднозначно | Два файла с одним коротким именем. Укажите путь Views/Form.cshtml |
В заголовке видны символы < | В модель попал уже закодированный текст, и Razor закодировал его ещё раз. В модель кладите обычную строку |
Кавычки JSON пикера превратились в " | Фрагмент вставлен через @Model.Json. Для готового JSON нужен @Html.Raw |
<form> внезапно получил скрытые поля ASP.NET | В _ViewImports.cshtml подключён весь набор tag helper. Оставьте только PartialTagHelper |
| Кнопка ничего не вызывает | Нет data-wp-action, либо type="submit" увёл браузер со страницы. Ставьте type="button" |
Поле формы не пришло в HandleActionAsync | У поля нет name, оно disabled, либо кнопка собирает другую форму. Оберните поля и кнопку в один <form> |
| После правки на странице старая разметка | Не увеличена version или пакет не загружен заново |
Ошибка в .cshtml (не закрытый @if, неизвестное свойство модели) видна на dotnet publish: пакет не соберётся. Это происходит до установки на портал.
9. Прежний способ
Заголовок раздела «9. Прежний способ»Вебчасти, которые уже собраны строками C# или через WebPartTemplate и Templates/*.html, менять не требуется.
IWebPart,WebPartResultиdata-wp-actionте же.WebPartTemplate.LoadAndApplyпо-прежнему подставляет{{ключ}}буквально. Значения по-прежнему кодируют до подстановки. Если прогнать такой шаблон через Razor, уже закодированный текст испортится, поэтому старые файлы оставляют наWebPartTemplate.- Проект без
.cshtmlсобирается прежнимMicrosoft.NET.Sdkи прежнимpack-portalpart.
Новую разметку с циклами и формами пишите в Views/*.cshtml. Короткий кусок вроде одной подсказки «укажите список» можно оставить строкой в C#:
return new WebPartResult("""<p class="webpart-hint">Укажите список в настройках WebPart.</p>""");Подробности старого способа: HTML-шаблоны.