diff --git a/src/Jiaowu.Api/Controllers/NotificationsController.cs b/src/Jiaowu.Api/Controllers/NotificationsController.cs index fc18113..34635d0 100644 --- a/src/Jiaowu.Api/Controllers/NotificationsController.cs +++ b/src/Jiaowu.Api/Controllers/NotificationsController.cs @@ -872,8 +872,8 @@ public sealed class NotificationsController( } public sealed record SendMessageRequest( - [property: Required, StringLength(200)] string Title, - [property: Required, StringLength(20000)] string Content, + [Required, StringLength(200)] string Title, + [Required, StringLength(20000)] string Content, Guid? TeachingTaskId = null, MessageRecipientMode RecipientMode = MessageRecipientMode.Scope, MessageRecipientFilter? RecipientFilter = null, diff --git a/tests/Jiaowu.Api.Tests/NotificationsControllerTests.cs b/tests/Jiaowu.Api.Tests/NotificationsControllerTests.cs index 509a46b..be9acf1 100644 --- a/tests/Jiaowu.Api.Tests/NotificationsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/NotificationsControllerTests.cs @@ -3,14 +3,50 @@ using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.AspNetCore.Routing; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace Jiaowu.Api.Tests; public sealed class NotificationsControllerTests { + [Fact] + public void Send_message_request_validation_metadata_is_compatible_with_mvc_record_binding() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddControllers() + .AddApplicationPart(typeof(NotificationsController).Assembly); + + using var serviceProvider = services.BuildServiceProvider(); + var objectValidator = serviceProvider.GetRequiredService(); + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + var actionContext = new ActionContext( + httpContext, + new RouteData(), + new ActionDescriptor(), + new ModelStateDictionary()); + var request = new SendMessageRequest( + "校内通知", + "

这是一条用于验证请求模型绑定的通知。

"); + + var exception = Record.Exception(() => + objectValidator.Validate(actionContext, null, string.Empty, request)); + + Assert.Null(exception); + Assert.True(actionContext.ModelState.IsValid); + } + [Fact] public async Task College_administrator_can_only_send_to_enabled_users_in_own_college() { diff --git a/web/src/components/RichMessageContent.vue b/web/src/components/RichMessageContent.vue index 8fed3c2..158a6bb 100644 --- a/web/src/components/RichMessageContent.vue +++ b/web/src/components/RichMessageContent.vue @@ -11,8 +11,14 @@ const safeHtml = computed(() => DOMPurify.sanitize(props.content, { ALLOWED_TAGS: [ 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'blockquote', 'a', + 'figure', 'figcaption', 'img', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'colgroup', 'col', + ], + ALLOWED_ATTR: [ + 'href', 'target', 'rel', + 'src', 'alt', 'title', 'width', 'height', 'class', 'style', + 'colspan', 'rowspan', 'scope', ], - ALLOWED_ATTR: ['href', 'target', 'rel'], ALLOW_DATA_ATTR: false, })) @@ -35,6 +41,12 @@ const safeHtml = computed(() => DOMPurify.sanitize(props.content, { line-height: 1.75; } +.rich-message-content::after { + display: block; + clear: both; + content: ''; +} + .rich-message-content :deep(p), .rich-message-content :deep(ul), .rich-message-content :deep(ol), @@ -77,4 +89,77 @@ const safeHtml = computed(() => DOMPurify.sanitize(props.content, { text-decoration: underline; text-underline-offset: 2px; } + +.rich-message-content :deep(figure) { + margin: .9em auto; +} + +.rich-message-content :deep(figure.image) { + max-width: 100%; +} + +.rich-message-content :deep(img) { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; + border-radius: 6px; +} + +.rich-message-content :deep(figcaption) { + margin-top: .4em; + color: var(--muted); + font-size: .86em; + line-height: 1.5; + text-align: center; +} + +.rich-message-content :deep(.image-style-align-left), +.rich-message-content :deep(.image-style-block-align-left) { + margin-right: 1.25em; + margin-left: 0; +} + +.rich-message-content :deep(.image-style-align-right), +.rich-message-content :deep(.image-style-block-align-right), +.rich-message-content :deep(.image-style-side) { + margin-right: 0; + margin-left: auto; +} + +.rich-message-content :deep(.image-style-wrap-text.image-style-align-left) { + float: left; +} + +.rich-message-content :deep(.image-style-wrap-text.image-style-align-right), +.rich-message-content :deep(.image-style-side) { + float: right; + margin-left: 1.25em; +} + +.rich-message-content :deep(figure.table) { + max-width: 100%; + overflow-x: auto; +} + +.rich-message-content :deep(table) { + width: 100%; + border-collapse: collapse; + background: #fff; +} + +.rich-message-content :deep(th), +.rich-message-content :deep(td) { + min-width: 4.5em; + padding: .55em .7em; + border: 1px solid var(--line); + vertical-align: top; +} + +.rich-message-content :deep(th) { + background: #f3f5f8; + color: var(--ink); + font-weight: 650; + text-align: left; +} diff --git a/web/src/views/NotificationCenterView.vue b/web/src/views/NotificationCenterView.vue index a90d17d..c064ca1 100644 --- a/web/src/views/NotificationCenterView.vue +++ b/web/src/views/NotificationCenterView.vue @@ -13,15 +13,30 @@ import { } from '@element-plus/icons-vue' import { Ckeditor } from '@ckeditor/ckeditor5-vue' import { + AutoLink, BlockQuote, Bold, ClassicEditor, Essentials, Heading, + Image, + ImageCaption, + ImageInsert, + ImageInsertViaUrl, + ImageResize, + ImageStyle, + ImageToolbar, Italic, Link, + LinkImage, List, Paragraph, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableProperties, + TableToolbar, type EditorConfig, } from 'ckeditor5' import 'ckeditor5/ckeditor5.css' @@ -84,8 +99,23 @@ const editorConfig: EditorConfig = { Bold, Italic, Link, + AutoLink, List, BlockQuote, + Image, + ImageCaption, + ImageInsert, + ImageInsertViaUrl, + ImageResize, + ImageStyle, + ImageToolbar, + LinkImage, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableProperties, + TableToolbar, ], toolbar: { items: [ @@ -95,6 +125,9 @@ const editorConfig: EditorConfig = { 'italic', 'link', '|', + 'insertTable', + 'insertImage', + '|', 'bulletedList', 'numberedList', 'blockQuote', @@ -115,6 +148,32 @@ const editorConfig: EditorConfig = { addTargetToExternalLinks: true, defaultProtocol: 'https://', }, + image: { + insert: { + integrations: ['insertImageViaUrl'], + }, + toolbar: [ + 'toggleImageCaption', + 'imageTextAlternative', + '|', + 'imageStyle:inline', + 'imageStyle:wrapText', + 'imageStyle:breakText', + '|', + 'resizeImage', + 'linkImage', + ], + }, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells', + 'toggleTableCaption', + 'tableProperties', + 'tableCellProperties', + ], + }, } const categories: Record = { @@ -808,7 +867,7 @@ onMounted(() => load()) :config="editorConfig" />
- 支持标题、列表、引用和链接;不支持上传附件或图片。 + 支持表格、超链接和外链图片;图片请填写 HTTPS 地址,不上传本地文件。 {{ editorTextLength }} 字 · 源码 {{ messageForm.content.length }}/20000