From 2eddb1217ea2d86c7de5c66d2738c1e8cf27ab42 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 29 Aug 2026 11:31:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=B9=B6=E5=AE=8C=E6=88=90?= =?UTF-8?q?=20Razor=20Pages=20+=20Blazor=20Web=20App=20=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E3=80=82=20-=20=E9=A1=B9=E7=9B=AE=E7=8E=B0=E4=B8=BA=20net10.0?= =?UTF-8?q?=EF=BC=8C=E4=BD=BF=E7=94=A8=E6=9C=AC=E6=9C=BA=20.NET=20SDK=2010?= =?UTF-8?q?.0.400=20/=20runtime=2010.0.11=E3=80=82=20-=20MySQL=20=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=E5=BD=93=E5=89=8D=E6=9C=80=E6=96=B0=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=98=AF=20Pomelo=209.0=EF=BC=8C=E5=9B=A0?= =?UTF-8?q?=E6=AD=A4=20EF=20Core=20=E4=BF=9D=E6=8C=81=E5=9C=A8=209.0.11?= =?UTF-8?q?=EF=BC=9B=E8=BF=81=E7=A7=BB=E5=B7=B2=E5=8F=AF=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E3=80=82=20-=20Razor=20Pages=20=E9=9D=99?= =?UTF-8?q?=E6=80=81=20SSR=EF=BC=9A=20=20=20-=20[=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E9=A6=96=E9=A1=B5](E:\\election=20system\\src\\ElectionSystem.?= =?UTF-8?q?Api\\Pages\\Index.cshtml)=20=20=20-=20[=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E5=85=AC=E5=91=8A=E7=A4=BA=E4=BE=8B](E:\\election=20system\\sr?= =?UTF-8?q?c\\ElectionSystem.Api\\Pages\\Content\\Notice.cshtml)=20-=20Bla?= =?UTF-8?q?zor=20Interactive=20Server=EF=BC=9A=20=20=20-=20[=E6=8A=95?= =?UTF-8?q?=E7=A5=A8=E4=BA=A4=E4=BA=92=E9=A2=84=E8=A7=88](E:\\election=20s?= =?UTF-8?q?ystem\\src\\ElectionSystem.Api\\Components\\Pages\\BallotPrevie?= =?UTF-8?q?w.razor)=EF=BC=8C=E4=BB=85=E6=BC=94=E7=A4=BA=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E6=8F=90=E4=BA=A4=E6=88=96=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E9=80=89=E7=A5=A8=E3=80=82=20-=20=E5=B7=B2=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=20Blazor=20=E6=89=80=E9=9C=80=E7=9A=84=E5=8F=8D=E4=BC=AA?= =?UTF-8?q?=E9=80=A0=E4=B8=AD=E9=97=B4=E4=BB=B6=EF=BC=8C=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=90=8C=E6=AD=A5=E6=9B=B4=E6=96=B0=EF=BC=9A?= =?UTF-8?q?[architecture.md](E:\\election=20system\\docs\\architecture.md)?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 2 + dotnet-tools.json | 2 +- scripts/verify-pages.ps1 | 59 +++++++++++++++++++ src/ElectionSystem.Api/Components/App.razor | 15 +++++ .../Components/Layout/MainLayout.razor | 12 ++++ .../Components/Pages/BallotPreview.razor | 47 +++++++++++++++ .../Components/Routes.razor | 11 ++++ .../Components/_Imports.razor | 4 ++ .../ElectionSystem.Api.csproj | 9 ++- .../Pages/Content/Notice.cshtml | 24 ++++++++ .../Pages/Content/Notice.cshtml.cs | 8 +++ src/ElectionSystem.Api/Pages/Index.cshtml | 27 +++++++++ src/ElectionSystem.Api/Pages/Index.cshtml.cs | 8 +++ .../Pages/_ViewImports.cshtml | 1 + src/ElectionSystem.Api/Program.cs | 7 +++ src/ElectionSystem.Api/wwwroot/css/site.css | 18 ++++++ .../ElectionSystem.Api.Tests.csproj | 6 +- 17 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 scripts/verify-pages.ps1 create mode 100644 src/ElectionSystem.Api/Components/App.razor create mode 100644 src/ElectionSystem.Api/Components/Layout/MainLayout.razor create mode 100644 src/ElectionSystem.Api/Components/Pages/BallotPreview.razor create mode 100644 src/ElectionSystem.Api/Components/Routes.razor create mode 100644 src/ElectionSystem.Api/Components/_Imports.razor create mode 100644 src/ElectionSystem.Api/Pages/Content/Notice.cshtml create mode 100644 src/ElectionSystem.Api/Pages/Content/Notice.cshtml.cs create mode 100644 src/ElectionSystem.Api/Pages/Index.cshtml create mode 100644 src/ElectionSystem.Api/Pages/Index.cshtml.cs create mode 100644 src/ElectionSystem.Api/Pages/_ViewImports.cshtml create mode 100644 src/ElectionSystem.Api/wwwroot/css/site.css diff --git a/docs/architecture.md b/docs/architecture.md index 96c590a..572baaf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,8 @@ `Identity` 仅维护本地用户与未来外部身份提供商的映射;`Organization` 维护组织树和成员范围;`Election`、`Voting`、`OfflineCounting`、`Cms`、`PublicBoard` 和 `Audit` 预留为独立业务模块。 +公开内容采用 Razor Pages 的静态服务端渲染;需要即时校验和状态更新的区域采用 Blazor Web App 的 Interactive Server 渲染。交互组件不能承担投票防重、资格或计票规则,这些规则仍必须由服务端事务和数据库约束执行。 + 线上投票实现时,资格/已投状态与匿名选票将使用不同实体和数据库约束,禁止常规查询将选民反向关联到选票内容。本阶段没有创建选票表,因此不会产生该关联。 ## MySQL 配置 diff --git a/dotnet-tools.json b/dotnet-tools.json index 3ac327f..ee846c9 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "8.0.17", + "version": "9.0.11", "commands": [ "dotnet-ef" ], diff --git a/scripts/verify-pages.ps1 b/scripts/verify-pages.ps1 new file mode 100644 index 0000000..91f9076 --- /dev/null +++ b/scripts/verify-pages.ps1 @@ -0,0 +1,59 @@ +$ErrorActionPreference = 'Stop' + +$psi = [System.Diagnostics.ProcessStartInfo]::new() +$psi.FileName = 'dotnet' +$psi.UseShellExecute = $false +$psi.RedirectStandardOutput = $true +$psi.RedirectStandardError = $true +$psi.Environment['ASPNETCORE_ENVIRONMENT'] = 'Development' +$psi.Environment['Security__EnableHttpsRedirection'] = 'false' + +$argList = @( + 'run', '--no-build', '--configuration', 'Release', '--project', 'src/ElectionSystem.Api', + '--', '--urls', 'http://127.0.0.1:5055' +) +foreach ($arg in $argList) { + $null = $psi.ArgumentList.Add($arg) +} + +$process = [System.Diagnostics.Process]::Start($psi) +try { + $ready = $false + for ($i = 0; $i -lt 20; $i++) { + Start-Sleep -Milliseconds 500 + try { + $response = Invoke-WebRequest -Uri 'http://127.0.0.1:5055/' -UseBasicParsing -TimeoutSec 2 + if ($response.StatusCode -eq 200) { + $ready = $true + break + } + } catch { } + } + + if (-not $ready) { + throw "Application did not become ready. $($process.StandardError.ReadToEnd())" + } + + $homeResponse = Invoke-WebRequest -Uri 'http://127.0.0.1:5055/' -UseBasicParsing -TimeoutSec 5 + $notice = Invoke-WebRequest -Uri 'http://127.0.0.1:5055/content/notice' -UseBasicParsing -TimeoutSec 5 + $interactive = Invoke-WebRequest -Uri 'http://127.0.0.1:5055/ballot-preview' -UseBasicParsing -TimeoutSec 5 + + if ($homeResponse.Content -notmatch '公开内容以服务端渲染快速交付') { throw 'Home page content check failed.' } + if ($notice.Content -notmatch '公开公告模板') { throw 'Notice page content check failed.' } + if ($interactive.Content -notmatch '投票交互预览') { throw 'Interactive component content check failed.' } + + "Runtime page checks passed: / ($($homeResponse.StatusCode)), /content/notice ($($notice.StatusCode)), /ballot-preview ($($interactive.StatusCode))" +} catch { + if (-not $process.HasExited) { + $process.Kill($true) + $process.WaitForExit() + } + $serverError = $process.StandardError.ReadToEnd() + $serverOutput = $process.StandardOutput.ReadToEnd() + throw "Runtime verification failed: $($_.Exception.Message)`n$serverError`n$serverOutput" +} finally { + if (-not $process.HasExited) { + $process.Kill($true) + $process.WaitForExit() + } +} diff --git a/src/ElectionSystem.Api/Components/App.razor b/src/ElectionSystem.Api/Components/App.razor new file mode 100644 index 0000000..0f0d994 --- /dev/null +++ b/src/ElectionSystem.Api/Components/App.razor @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/ElectionSystem.Api/Components/Layout/MainLayout.razor b/src/ElectionSystem.Api/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..1644068 --- /dev/null +++ b/src/ElectionSystem.Api/Components/Layout/MainLayout.razor @@ -0,0 +1,12 @@ +@inherits LayoutComponentBase + + +@Body +
所有时间和结果以活动正式公告为准。
diff --git a/src/ElectionSystem.Api/Components/Pages/BallotPreview.razor b/src/ElectionSystem.Api/Components/Pages/BallotPreview.razor new file mode 100644 index 0000000..85bd782 --- /dev/null +++ b/src/ElectionSystem.Api/Components/Pages/BallotPreview.razor @@ -0,0 +1,47 @@ +@page "/ballot-preview" +@rendermode InteractiveServer + +投票交互预览 + +
+

INTERACTIVE SERVER

+

投票交互预览

+

此页面用于验证嵌入式 Blazor Server 交互能力,不连接选举数据,也不会提交或记录任何选票。

+ +
+

模拟选择

+ @foreach (var candidate in candidates) + { + + } +

当前已选:@selected.Count 人(本活动最多可选 2 人)。

+ @if (selectionError is not null) + { + + } +
+
+ +@code { + private readonly string[] candidates = ["候选人甲", "候选人乙", "候选人丙"]; + private readonly HashSet selected = []; + private string? selectionError; + + private void Toggle(string candidate) + { + selectionError = null; + if (!selected.Add(candidate)) + { + selected.Remove(candidate); + return; + } + if (selected.Count > 2) + { + selected.Remove(candidate); + selectionError = "最多只能选择 2 人。"; + } + } +} diff --git a/src/ElectionSystem.Api/Components/Routes.razor b/src/ElectionSystem.Api/Components/Routes.razor new file mode 100644 index 0000000..e09470a --- /dev/null +++ b/src/ElectionSystem.Api/Components/Routes.razor @@ -0,0 +1,11 @@ + + + + + + + +

页面不存在

请返回公开首页。

+
+
+
diff --git a/src/ElectionSystem.Api/Components/_Imports.razor b/src/ElectionSystem.Api/Components/_Imports.razor new file mode 100644 index 0000000..a2cc7c8 --- /dev/null +++ b/src/ElectionSystem.Api/Components/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode diff --git a/src/ElectionSystem.Api/ElectionSystem.Api.csproj b/src/ElectionSystem.Api/ElectionSystem.Api.csproj index 0f76d1e..1523fc2 100644 --- a/src/ElectionSystem.Api/ElectionSystem.Api.csproj +++ b/src/ElectionSystem.Api/ElectionSystem.Api.csproj @@ -1,20 +1,19 @@ - net8.0 + net10.0 enable enable election-system-api-2fb612a9-5cf4-464f-a465-31b31cc9698a - - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + diff --git a/src/ElectionSystem.Api/Pages/Content/Notice.cshtml b/src/ElectionSystem.Api/Pages/Content/Notice.cshtml new file mode 100644 index 0000000..6069785 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Content/Notice.cshtml @@ -0,0 +1,24 @@ +@page +@model ElectionSystem.Api.Pages.Content.NoticeModel +@{ + ViewData["Title"] = "公开公告"; +} + + + + + + 公开公告 + + + + +
+

NOTICE

+

公开公告模板

+

这是一个静态 SSR 页面示例。CMS 接入后,已审核发布的公告将从服务端读取,并可配置缓存和 SEO 元数据。

+

未发布内容、未确认线下计票和选民个人数据不会经由公开路由输出。

+
+
所有时间和结果以活动正式公告为准。
+ + diff --git a/src/ElectionSystem.Api/Pages/Content/Notice.cshtml.cs b/src/ElectionSystem.Api/Pages/Content/Notice.cshtml.cs new file mode 100644 index 0000000..3153ade --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Content/Notice.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace ElectionSystem.Api.Pages.Content; + +public sealed class NoticeModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/ElectionSystem.Api/Pages/Index.cshtml b/src/ElectionSystem.Api/Pages/Index.cshtml new file mode 100644 index 0000000..ba9a985 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Index.cshtml @@ -0,0 +1,27 @@ +@page +@model ElectionSystem.Api.Pages.IndexModel +@{ + ViewData["Title"] = "公开首页"; +} + + + + + + 选举与投票信息系统 + + + + +
+

PUBLIC INFORMATION

+

公开内容以服务端渲染快速交付

+

公告、候选人公示和正式结果将由 Razor Pages 呈现;具体投票和管理交互将按需嵌入 Blazor,并始终由服务端校验资格与规则。

+ 查看公告模板 +
+
所有时间和结果以活动正式公告为准。
+ + diff --git a/src/ElectionSystem.Api/Pages/Index.cshtml.cs b/src/ElectionSystem.Api/Pages/Index.cshtml.cs new file mode 100644 index 0000000..7b41d9c --- /dev/null +++ b/src/ElectionSystem.Api/Pages/Index.cshtml.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace ElectionSystem.Api.Pages; + +public sealed class IndexModel : PageModel +{ + public void OnGet() { } +} diff --git a/src/ElectionSystem.Api/Pages/_ViewImports.cshtml b/src/ElectionSystem.Api/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..a757b41 --- /dev/null +++ b/src/ElectionSystem.Api/Pages/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/ElectionSystem.Api/Program.cs b/src/ElectionSystem.Api/Program.cs index 403e25d..37e9100 100644 --- a/src/ElectionSystem.Api/Program.cs +++ b/src/ElectionSystem.Api/Program.cs @@ -1,4 +1,5 @@ using ElectionSystem.Api.Common.Errors; +using ElectionSystem.Api.Components; using ElectionSystem.Api.Infrastructure.Auditing; using ElectionSystem.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -13,6 +14,8 @@ if (string.IsNullOrWhiteSpace(connectionString)) } builder.Services.AddControllers(); +builder.Services.AddRazorPages(); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddProblemDetails(); @@ -39,7 +42,11 @@ if (app.Environment.IsDevelopment()) } app.UseAuthorization(); +app.UseAntiforgery(); +app.MapStaticAssets(); app.MapControllers(); +app.MapRazorPages(); +app.MapRazorComponents().AddInteractiveServerRenderMode(); app.MapHealthChecks("/health", new HealthCheckOptions { ResponseWriter = HealthCheckResponseWriter.WriteAsync }).AllowAnonymous(); app.Run(); diff --git a/src/ElectionSystem.Api/wwwroot/css/site.css b/src/ElectionSystem.Api/wwwroot/css/site.css new file mode 100644 index 0000000..9cca308 --- /dev/null +++ b/src/ElectionSystem.Api/wwwroot/css/site.css @@ -0,0 +1,18 @@ +:root { color-scheme: light; font-family: "Microsoft YaHei", system-ui, sans-serif; color: #1c2b38; background: #f6f8f7; } +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; } +.site-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 1rem max(1.5rem, calc((100vw - 72rem) / 2)); background: #123c4a; color: #fff; } +.site-header a { color: inherit; text-decoration: none; } +.site-header nav { display: flex; gap: 1rem; font-size: .95rem; } +.brand { font-weight: 700; letter-spacing: .02em; } +.container { width: min(72rem, calc(100% - 3rem)); margin: 0 auto; padding: 4rem 0; } +.hero { max-width: 56rem; } +.hero h1 { font-size: clamp(2rem, 5vw, 4rem); line-height: 1.15; margin: .2rem 0 1rem; } +.eyebrow { color: #16717e; font-weight: 700; font-size: .78rem; letter-spacing: .12em; } +.button { display: inline-block; padding: .75rem 1rem; color: #fff; background: #16717e; border-radius: .4rem; text-decoration: none; } +.panel { max-width: 36rem; padding: 1.5rem; border: 1px solid #d6dfdc; background: #fff; border-radius: .6rem; } +.option { display: block; padding: .65rem 0; border-bottom: 1px solid #e8eeeb; } +.error { color: #a02d2d; } +.article { max-width: 52rem; font-size: 1.08rem; line-height: 1.7; } +.site-footer { padding: 2rem max(1.5rem, calc((100vw - 72rem) / 2)); color: #52616b; font-size: .9rem; } +@media (max-width: 40rem) { .site-header { align-items: flex-start; flex-direction: column; } .container { padding: 2.5rem 0; } } diff --git a/tests/ElectionSystem.Api.Tests/ElectionSystem.Api.Tests.csproj b/tests/ElectionSystem.Api.Tests/ElectionSystem.Api.Tests.csproj index c508ea0..c22525b 100644 --- a/tests/ElectionSystem.Api.Tests/ElectionSystem.Api.Tests.csproj +++ b/tests/ElectionSystem.Api.Tests/ElectionSystem.Api.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable @@ -11,8 +11,8 @@ - - + +