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 交互能力,不连接选举数据,也不会提交或记录任何选票。
+
+
+
+
+@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 @@
-
-
+
+