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();
}
}