diff --git a/docs/cms-setup.md b/docs/cms-setup.md index e8cfa81..33e53ce 100644 --- a/docs/cms-setup.md +++ b/docs/cms-setup.md @@ -10,6 +10,8 @@ 初始化完成后,在 **Configuration → Features** 启用 `ElectionSystem.Cms.PublicContent`。该模块的数据迁移会自动创建下列固定公开栏目和公共元数据字段;停用模块不会删除已有公开内容。 +启用后请刷新后台(或重新登录)。左侧 **Content → Information Disclosure** 会出现八个固定栏目入口,每个入口直接打开对应内容类型的筛选列表并可新建内容。若入口不可见,确认当前角色已拥有 Orchard Core 的 **List Content** 权限;站点管理员默认具备该权限。 + ## 公开前台接入 选举主程序的 Razor Pages 通过 CMS 主机的只读 `GET /api/public-content` 接口读取内容,绝不直连 CMS 数据库。该接口只允许固定公开内容类型,并且只返回已发布版本的标题、正文、发布时间和公开元数据;草稿、历史版本、选民资料、投票状态、线下计票和内部审计记录均不会返回。 diff --git a/src/ElectionSystem.Api/Properties/launchSettings.json b/src/ElectionSystem.Api/Properties/launchSettings.json index 87ca808..b0328db 100644 --- a/src/ElectionSystem.Api/Properties/launchSettings.json +++ b/src/ElectionSystem.Api/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "", "applicationUrl": "http://localhost:5037", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -23,7 +23,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "", "applicationUrl": "https://localhost:7053;http://localhost:5037", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/AdminMenu.cs b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/AdminMenu.cs new file mode 100644 index 0000000..98fa032 --- /dev/null +++ b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/AdminMenu.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using OrchardCore.Contents; +using OrchardCore.Navigation; +using OrchardCore.Security.Permissions; + +namespace ElectionSystem.Cms.PublicContent; + +/// +/// Makes the fixed election disclosure sections visible in the CMS administration menu. +/// +public sealed class AdminMenu(IStringLocalizer stringLocalizer) : AdminNavigationProvider +{ + private readonly IStringLocalizer _stringLocalizer = stringLocalizer; + + protected override ValueTask BuildAsync(NavigationBuilder builder) + { + builder.Add(_stringLocalizer["Content"], content => content + .Add(_stringLocalizer["Information Disclosure"], "10", disclosure => disclosure + .AddClass("election-public-content") + .Id("election-public-content") + .LinkToFirstChild(true) + .Add(_stringLocalizer["Election Notices"], "1", item => AddContentList(item, "ElectionNotice")) + .Add(_stringLocalizer["Rules and Policies"], "2", item => AddContentList(item, "PolicyDocument")) + .Add(_stringLocalizer["Candidate Disclosures"], "3", item => AddContentList(item, "CandidateDisclosure")) + .Add(_stringLocalizer["Voter Guides"], "4", item => AddContentList(item, "VoterGuide")) + .Add(_stringLocalizer["Result Announcements"], "5", item => AddContentList(item, "ResultAnnouncement")) + .Add(_stringLocalizer["Frequently Asked Questions"], "6", item => AddContentList(item, "PublicFaq")) + .Add(_stringLocalizer["Downloads"], "7", item => AddContentList(item, "DownloadAsset")) + .Add(_stringLocalizer["Archives"], "8", item => AddContentList(item, "ArchiveRecord")))); + + return ValueTask.CompletedTask; + } + + private static NavigationItemBuilder AddContentList(NavigationItemBuilder item, string contentType) => item + .Action("List", "Admin", new RouteValueDictionary + { + ["area"] = "OrchardCore.Contents", + ["contentTypeId"] = contentType + }) + .Permission(CommonPermissions.ListContent) + .LocalNav(); +} diff --git a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Controllers/PublicContentController.cs b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Controllers/PublicContentController.cs index e43cab1..28424dc 100644 --- a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Controllers/PublicContentController.cs +++ b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Controllers/PublicContentController.cs @@ -81,7 +81,31 @@ public sealed class PublicContentController(ISession session) : ControllerBase ReadString(item.Content, "ElectionPublicMetadataPart", "EffectiveToUtc")); private static string? ReadString(JsonObject content, string partName, string fieldName) => - content[partName]?[fieldName]?.GetValue(); + ReadScalar(content[partName]?[fieldName]); + + private static string? ReadScalar(JsonNode? node, int depth = 0) + { + if (node is JsonValue value) + { + return value.TryGetValue(out var text) ? text : value.ToString(); + } + + // Content fields can be represented as objects depending on the Orchard editor/field type. + // Only expose a conventional scalar child; never serialize an arbitrary CMS object into the public response. + if (node is JsonObject obj && depth < 2) + { + foreach (var propertyName in new[] { "Text", "Html", "Value", "Url", "Path" }) + { + var text = ReadScalar(obj[propertyName], depth + 1); + if (!string.IsNullOrWhiteSpace(text)) + { + return text; + } + } + } + + return null; + } } public sealed record PublicContentSummary( diff --git a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/ElectionSystem.Cms.PublicContent.csproj b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/ElectionSystem.Cms.PublicContent.csproj index 5b00c2e..f746236 100644 --- a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/ElectionSystem.Cms.PublicContent.csproj +++ b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/ElectionSystem.Cms.PublicContent.csproj @@ -18,6 +18,7 @@ + diff --git a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Migrations.cs b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Migrations.cs index b68ea95..447fec8 100644 --- a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Migrations.cs +++ b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Migrations.cs @@ -1,51 +1,29 @@ using OrchardCore.ContentManagement.Metadata; -using OrchardCore.ContentManagement.Metadata.Settings; using OrchardCore.Data.Migration; +using Microsoft.Extensions.Logging; namespace ElectionSystem.Cms.PublicContent; -public sealed class Migrations(IContentDefinitionManager contentDefinitionManager) : DataMigration +public sealed class Migrations( + IContentDefinitionManager contentDefinitionManager, + ILogger logger) : DataMigration { public async Task CreateAsync() { - await contentDefinitionManager.AlterPartDefinitionAsync("ElectionPublicMetadataPart", part => part - .WithDescription("公开内容的活动外部标识、适用范围和更新时间。不得保存选民身份、投票状态或计票明细。") - .WithField("ElectionReference", field => field - .OfType("TextField") - .WithDisplayName("选举活动外部标识")) - .WithField("AudienceScope", field => field - .OfType("MultiTextField") - .WithDisplayName("适用受众")) - .WithField("EffectiveFromUtc", field => field - .OfType("DateTimeField") - .WithDisplayName("生效时间(UTC)")) - .WithField("EffectiveToUtc", field => field - .OfType("DateTimeField") - .WithDisplayName("失效时间(UTC)"))); - - await CreatePublicContentTypeAsync("ElectionNotice", "选举公告"); - await CreatePublicContentTypeAsync("PolicyDocument", "规则与政策"); - await CreatePublicContentTypeAsync("CandidateDisclosure", "候选人公示"); - await CreatePublicContentTypeAsync("VoterGuide", "选民指南"); - await CreatePublicContentTypeAsync("ResultAnnouncement", "结果公告"); - await CreatePublicContentTypeAsync("PublicFaq", "常见问题"); - await CreatePublicContentTypeAsync("DownloadAsset", "资料下载"); - await CreatePublicContentTypeAsync("ArchiveRecord", "历史归档"); - + await EnsurePublicContentDefinitionsAsync(); return 1; } - private Task CreatePublicContentTypeAsync(string name, string displayName) => - contentDefinitionManager.AlterTypeDefinitionAsync(name, type => type - .WithDisplayName(displayName) - .WithCategory("信息公开") - .Creatable() - .Draftable() - .Versionable() - .Securable() - .WithPart("TitlePart") - .WithPart("HtmlBodyPart") - .WithPart("AutoroutePart") - .WithPart("SeoMetaPart") - .WithPart("ElectionPublicMetadataPart")); + // Existing sites may have enabled an earlier module build that did not create every definition. + // Altering definitions is idempotent and repairs that state without deleting published content. + public async Task UpdateFrom1Async() + { + await EnsurePublicContentDefinitionsAsync(); + return 2; + } + + private async Task EnsurePublicContentDefinitionsAsync() + { + await PublicContentDefinitions.ApplyAsync(contentDefinitionManager, logger); + } } diff --git a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/PublicContentDefinitionSynchronizer.cs b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/PublicContentDefinitionSynchronizer.cs new file mode 100644 index 0000000..190e09f --- /dev/null +++ b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/PublicContentDefinitionSynchronizer.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using OrchardCore.ContentManagement.Metadata; + +namespace ElectionSystem.Cms.PublicContent; + +/// +/// Tenant-start fallback for sites that enabled the feature before its data migration was available. +/// It only writes missing definitions and is safe to run repeatedly. +/// +public sealed class PublicContentDefinitionSynchronizer( + IContentDefinitionManager contentDefinitionManager, + ILogger logger) +{ + public async Task EnsureAsync() + { + foreach (var contentTypeName in PublicContentDefinitions.ContentTypeNames) + { + if (await contentDefinitionManager.GetTypeDefinitionAsync(contentTypeName) is null) + { + logger.LogWarning("A fixed election public-content definition is missing; applying the module definitions."); + await PublicContentDefinitions.ApplyAsync(contentDefinitionManager, logger); + return; + } + } + } +} diff --git a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/PublicContentDefinitions.cs b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/PublicContentDefinitions.cs new file mode 100644 index 0000000..af1f874 --- /dev/null +++ b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/PublicContentDefinitions.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Logging; +using OrchardCore.ContentManagement.Metadata; +using OrchardCore.ContentManagement.Metadata.Settings; + +namespace ElectionSystem.Cms.PublicContent; + +internal static class PublicContentDefinitions +{ + internal static readonly string[] ContentTypeNames = + [ + "ElectionNotice", + "PolicyDocument", + "CandidateDisclosure", + "VoterGuide", + "ResultAnnouncement", + "PublicFaq", + "DownloadAsset", + "ArchiveRecord" + ]; + + public static async Task ApplyAsync(IContentDefinitionManager contentDefinitionManager, ILogger logger) + { + logger.LogInformation("Ensuring fixed election public-content definitions."); + await contentDefinitionManager.AlterPartDefinitionAsync("ElectionPublicMetadataPart", part => part + .WithDescription("公开内容的活动外部标识、适用范围和更新时间。不得保存选民身份、投票状态或计票明细。") + .WithField("ElectionReference", field => field.OfType("TextField").WithDisplayName("选举活动外部标识")) + .WithField("AudienceScope", field => field.OfType("MultiTextField").WithDisplayName("适用受众")) + .WithField("EffectiveFromUtc", field => field.OfType("DateTimeField").WithDisplayName("生效时间(UTC)")) + .WithField("EffectiveToUtc", field => field.OfType("DateTimeField").WithDisplayName("失效时间(UTC)"))); + + await CreatePublicContentTypeAsync(contentDefinitionManager, "ElectionNotice", "选举公告"); + await CreatePublicContentTypeAsync(contentDefinitionManager, "PolicyDocument", "规则与政策"); + await CreatePublicContentTypeAsync(contentDefinitionManager, "CandidateDisclosure", "候选人公示"); + await CreatePublicContentTypeAsync(contentDefinitionManager, "VoterGuide", "选民指南"); + await CreatePublicContentTypeAsync(contentDefinitionManager, "ResultAnnouncement", "结果公告"); + await CreatePublicContentTypeAsync(contentDefinitionManager, "PublicFaq", "常见问题"); + await CreatePublicContentTypeAsync(contentDefinitionManager, "DownloadAsset", "资料下载"); + await CreatePublicContentTypeAsync(contentDefinitionManager, "ArchiveRecord", "历史归档"); + } + + private static Task CreatePublicContentTypeAsync(IContentDefinitionManager contentDefinitionManager, string name, string displayName) => + contentDefinitionManager.AlterTypeDefinitionAsync(name, type => type + .WithDisplayName(displayName) + .WithCategory("信息公开") + .Creatable() + .Draftable() + .Versionable() + .Securable() + .WithPart("TitlePart") + .WithPart("HtmlBodyPart") + .WithPart("AutoroutePart") + .WithPart("SeoMetaPart") + .WithPart("ElectionPublicMetadataPart")); +} diff --git a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Startup.cs b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Startup.cs index 1912dd6..34928e2 100644 --- a/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Startup.cs +++ b/src/ElectionSystem.Cms.Modules/ElectionSystem.Cms.PublicContent/Startup.cs @@ -1,5 +1,8 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; using OrchardCore.Modules; +using OrchardCore.Navigation; namespace ElectionSystem.Cms.PublicContent; @@ -7,6 +10,16 @@ public sealed class Startup : StartupBase { public override void ConfigureServices(IServiceCollection services) { + services.AddScoped(); + services.AddScoped(); + } + + public override async ValueTask ConfigureAsync( + IApplicationBuilder app, + IEndpointRouteBuilder routes, + IServiceProvider serviceProvider) + { + await serviceProvider.GetRequiredService().EnsureAsync(); } }