diff --git a/TODO.md b/TODO.md
index a81a617..5251580 100644
--- a/TODO.md
+++ b/TODO.md
@@ -60,19 +60,19 @@
## 第四阶段:线下投票与计票复核
-- [ ] 管理线下场次:地点、时间、票箱/票站、工作人员、监票员。
+- [x] 管理线下计票批次:按已分配计票所、工作人员、监督员及活动截止时间控制;地点/票箱信息沿用计票所资料。
- [ ] 实现线下签到或名单核验,与线上已投状态交叉校验。
-- [ ] 支持按候选人录入有效票、无效票和弃权票。
-- [ ] 支持双人独立计票录入,自动比对并将差异送入复核。
-- [ ] 实现计票批次、复核、监票确认、封存附件与更正版本记录。
+- [x] 支持按候选人/议案选项录入有效票、无效票、弃权票和缺投数。
+- [x] 支持双人独立计票录入,自动比对并将差异送入复核。
+- [x] 实现计票批次、复核、监票确认与更正版本记录;封存附件待对象存储阶段接入。
- [ ] 已确认线下计票不得原地修改;更正必须保留前版本、原因、审核人与时间。
-- [ ] 仅将“已确认”的线下票纳入活动最终汇总。
+- [x] 仅将“已确认”的线下票纳入活动最终汇总。
## 第五阶段:结果、公开看板与 CMS
-- [ ] 按规则汇总线上有效票与已确认线下票,生成候选人得票、有效/无效/弃权统计及当选名单。
+- [x] 按活动规则汇总已确认线下票,生成候选人/议案选项得票、有效/无效/弃权/缺投统计及当选名单;线上投票接入后复用同一结果服务。
- [ ] 建立结果审核、监票确认、发布和归档流程。
-- [ ] SignalR 推送公开看板数据,断线后使用受控轮询兜底。
+- [x] SignalR 推送已确认线下计票的公开看板数据;断线后使用公开结果接口受控轮询兜底。
- [ ] 公开看板显示活动状态、参与人数、参与率、数据更新时间和公开规则说明。
- [ ] 候选人实时票数、排名和图表只在活动明确开启后公开;不得公开未确认线下结果。
- [x] CMS 公开信息基础:建立八类固定内容定义(选举公告、规则与政策、候选人公示、选民指南、结果公告、常见问题、资料下载、历史归档)及只读公开内容契约。
diff --git a/docs/offline-election-counting.md b/docs/offline-election-counting.md
new file mode 100644
index 0000000..b61deed
--- /dev/null
+++ b/docs/offline-election-counting.md
@@ -0,0 +1,30 @@
+# 线下选举发布与计票操作说明
+
+## 规则与权限
+
+- 活动必须指定一个目标行政区。只有直接被分配至该行政区的省、市、区、镇或村管理员可创建、编辑及发布;系统管理员可以全局操作。上级管理员仍可以只读查看其下级范围的活动。
+- 活动在草稿阶段依次配置议题、选项和计票所。发布后定义冻结,不能增删候选人、计票所或改变计票规则。
+- 一个活动可以包含多个 `CandidateElection`(岗位/多席位竞选)及 `Proposition`(议案)议题。每个议题独立记录每个选项的有效票、弃权、无效票和系统计算的缺投数。
+- 每个议题可配置席位数、最少/最多选择数、最高票/简单多数/绝对多数/特定比例门槛、计票分母(仅有效票或全部投出选票)以及平票后的人工复核或无结果处理。
+
+## 线下计票闭环
+
+1. 活动投票截止后,已分配的计票员在自己被分配的计票所创建计票批次。
+2. 两名不同计票员各自提交完整汇总。计票员输入本题计票张数、各选项有效票、弃权和无效票;缺投不可编辑,系统按 `max(0, 计票张数 × 最少选择数 − 有效选项票数 − 弃权 − 无效)` 自动计算。系统不保存选民身份、单张选票或选票内容,只保存计票所级的汇总数字和录入人。
+3. 两份录入逐项完全一致才进入“待监督确认”;不一致进入“待复核”。监督员填写原因后创建新的复点版本,原版本封存为已替代。
+4. 监督员不能确认自己的录入;确认后的批次不可编辑。需要更正时,监督员必须填写原因并创建新版本,旧版本保留为已替代。
+5. 只有当前已确认版本进入汇总和公开接口。所有指定计票所都确认后,计票截止任务自动发布最终结果;有任一未确认或差异批次时不会自动发布。
+
+## 对外公开
+
+- `GET /api/public/elections/{electionId}/results` 仅返回活动明确允许的数据。截止及结果发布前,默认只能按活动配置公开参与量;候选人或议案票数只有在 `PublishLiveCandidateTotals=true` 或最终结果发布后才返回。
+- SignalR 端点为 `/hubs/public-results`;客户端调用 `Subscribe(electionId)` 后接收 `resultUpdated`。推送只基于已确认的线下批次。
+- 每次确认、以及后台每分钟推进活动状态时都会更新公开数据。所有时间均为 UTC。
+
+## 管理 API
+
+- `POST /api/admin/elections` 创建草稿;`POST /{id}/contests`、`POST /{id}/contests/{contestId}/options`、`PUT /{id}/counting-stations` 配置;`POST /{id}/publish` 发布。
+- `POST /api/offline-counting/sessions` 开始计票;`POST /sessions/{id}/submissions` 双人独立录入;`POST /sessions/{id}/confirm` 监督确认。
+- `POST /sessions/{id}/resolve-discrepancy` 对不一致结果启动复点;`POST /sessions/{id}/corrections` 更正已确认版本。
+
+这些接口的写入均经过地域或计票所角色检查,并由现有保存拦截器产生审计记录。公开接口不包含工作人员身份、审计记录或未确认数据。
diff --git a/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor b/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor
index 96a2f63..57232d4 100644
--- a/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor
+++ b/src/ElectionSystem.Api/Components/Layout/AdminLayout.razor
@@ -13,6 +13,9 @@
投票与计票 / 发布
先配置岗位或议案、选项与计票所;活动发布后这些定义将被冻结。
正在加载授权范围…
} + else { +| 活动 | 地区 | 状态 | 截止时间 | |
|---|---|---|---|---|
| @election.Name | @regionNames.GetValueOrDefault(election.RegionId, "—") | @election.Status | @election.VotingEndsAtUtc.ToString("yyyy-MM-dd HH:mm") UTC |
状态:@selected.Status。@if (selected.Status != ElectionActivityStatus.Draft) { 已发布活动仅可查看。 }
+ @if (selected.Status == ElectionActivityStatus.Draft) { +| 议题 | 类型 | 规则 | 选项 |
|---|---|---|---|
| @contest.Title | @contest.Type | @contest.Determination / @contest.Seats 席 | @string.Join("、", options.Where(x => x.ElectionContestId == contest.Id).Select(x => x.Label)) |
投票与计票 / 线下
两名不同计票员独立录入;数据一致后由监督员确认,未确认数据不会进入公开结果。
正在加载可操作计票所…
} else { + + @if (sessions.Count > 0) {| 计票所 | 版本 | 状态 | 操作 |
|---|---|---|---|
| @stationNames.GetValueOrDefault(session.CountingStationId, "—") | V@session.Version | @session.Status | @if (session.Status == OfflineCountingSessionStatus.AwaitingSupervisor) { } |