前端支持cms
This commit is contained in:
1 parent
6b0c4d054d
commit
f56a63d4ce
16 files changed
+439
-10
No files matched your search
@@ -10,6 +10,14 @@
|
||||
|
||||
初始化完成后,在 **Configuration → Features** 启用 `ElectionSystem.Cms.PublicContent`。该模块的数据迁移会自动创建下列固定公开栏目和公共元数据字段;停用模块不会删除已有公开内容。
|
||||
|
||||
## 公开前台接入
|
||||
|
||||
选举主程序的 Razor Pages 通过 CMS 主机的只读 `GET /api/public-content` 接口读取内容,绝不直连 CMS 数据库。该接口只允许固定公开内容类型,并且只返回已发布版本的标题、正文、发布时间和公开元数据;草稿、历史版本、选民资料、投票状态、线下计票和内部审计记录均不会返回。
|
||||
|
||||
开发环境中主程序默认读取 `http://localhost:5065`。如 CMS 使用其他地址,配置主程序的 `Cms:PublicContentBaseUrl`(部署时使用环境变量 `Cms__PublicContentBaseUrl`)。CMS 未初始化、模块未启用或短暂不可用时,公开前台显示可恢复的“内容服务暂不可用”提示,选举业务不受影响。
|
||||
|
||||
内容编辑完成后必须先发布,才会出现在公开首页和 `/content` 各固定栏目中;前台内容详情固定为 `/content/item/{内容 ID}`,不接受 CMS 编辑器自定义路由来覆盖选举系统路由。
|
||||
|
||||
## 首批内容类型
|
||||
|
||||
在 CMS 的 Content Definition 中创建以下类型,并统一启用 Title、HTML/Markdown 正文、SEO、Taxonomy、Autoroute、Publish Later、Archive Later 和版本历史:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace ElectionSystem.Api.Modules.Cms;
|
||||
|
||||
/// <summary>
|
||||
/// Uses the CMS's deliberately small, read-only public contract. It never connects to the CMS database.
|
||||
/// </summary>
|
||||
public sealed class PublicContentClient(HttpClient httpClient, ILogger<PublicContentClient> logger)
|
||||
{
|
||||
public async Task<PublicContentFetch<IReadOnlyList<PublicContentSummary>>> ListAsync(
|
||||
string? contentType,
|
||||
int take,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestUri = contentType is null
|
||||
? $"api/public-content?take={take}"
|
||||
: $"api/public-content?contentType={Uri.EscapeDataString(contentType)}&take={take}";
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.GetAsync(requestUri, cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return PublicContentFetch<IReadOnlyList<PublicContentSummary>>.Unavailable;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadFromJsonAsync<List<PublicContentSummary>>(cancellationToken);
|
||||
return new PublicContentFetch<IReadOnlyList<PublicContentSummary>>(content ?? [], true);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "The CMS public-content endpoint is unavailable.");
|
||||
return PublicContentFetch<IReadOnlyList<PublicContentSummary>>.Unavailable;
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning("The CMS public-content endpoint timed out.");
|
||||
return PublicContentFetch<IReadOnlyList<PublicContentSummary>>.Unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PublicContentFetch<PublicContentDetail?>> GetAsync(string contentItemId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.GetAsync($"api/public-content/{Uri.EscapeDataString(contentItemId)}", cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return new PublicContentFetch<PublicContentDetail?>(null, true);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return new PublicContentFetch<PublicContentDetail?>(
|
||||
await response.Content.ReadFromJsonAsync<PublicContentDetail>(cancellationToken),
|
||||
true);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "The CMS public-content endpoint is unavailable.");
|
||||
return PublicContentFetch<PublicContentDetail?>.Unavailable;
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning("The CMS public-content endpoint timed out.");
|
||||
return PublicContentFetch<PublicContentDetail?>.Unavailable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PublicContentFetch<T>(T? Value, bool IsAvailable)
|
||||
{
|
||||
public static PublicContentFetch<T> Unavailable { get; } = new(default, false);
|
||||
}
|
||||
|
||||
public sealed record PublicContentSummary(
|
||||
string ContentItemId,
|
||||
string ContentType,
|
||||
string Title,
|
||||
string? RoutePath,
|
||||
DateTime? PublishedUtc);
|
||||
|
||||
public sealed record PublicContentDetail(
|
||||
string ContentItemId,
|
||||
string ContentType,
|
||||
string Title,
|
||||
string? RoutePath,
|
||||
string Html,
|
||||
DateTime? PublishedUtc,
|
||||
string? ElectionReference,
|
||||
string? EffectiveFromUtc,
|
||||
string? EffectiveToUtc);
|
||||
@@ -0,0 +1,34 @@
|
||||
@page "/content/item/{contentItemId}"
|
||||
@model ElectionSystem.Api.Pages.Content.DetailModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Item?.Title ?? "公开内容";
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@ViewData["Title"] - 选举与投票信息系统</title>
|
||||
<link rel="stylesheet" href="~/css/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header"><a class="brand" href="/">选举与投票信息系统</a><nav aria-label="主导航"><a href="/content">信息公开</a></nav></header>
|
||||
<main class="container article">
|
||||
@if (!Model.CmsAvailable)
|
||||
{
|
||||
<p class="status-note">信息公开服务暂不可用,请稍后刷新页面。</p>
|
||||
}
|
||||
else if (Model.Item is { } item)
|
||||
{
|
||||
<p class="eyebrow">@item.ContentType</p>
|
||||
<h1>@item.Title</h1>
|
||||
@if (item.PublishedUtc is { } publishedUtc)
|
||||
{
|
||||
<p class="published-at">发布时间:<time datetime="@publishedUtc.ToString("O")">@publishedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</time></p>
|
||||
}
|
||||
<div class="cms-body">@Html.Raw(item.Html)</div>
|
||||
}
|
||||
</main>
|
||||
<footer class="site-footer">所有时间和结果以活动正式公告为准。</footer>
|
||||
</body>
|
||||
</html>
|
||||
Loaded 3 of 16 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user