This commit is contained in:
zpc 2026-02-25 17:32:05 +08:00
parent 09be2bec81
commit d2a4f01e50
39 changed files with 2574 additions and 1 deletions

View File

@ -0,0 +1,733 @@
# 设计文档测评报告网页版PDF报告生成用
## 概述
测评报告网页版是学业邑规划测评系统的服务端渲染模块,集成在 MiAssessment.Api 项目中,使用 ASP.NET Core Razor Pages 渲染报告 HTML 页面。该模块为后端截图服务提供固定尺寸1309×926px的报告网页截图后与静态图片按配置顺序拼装为 PDF 报告文件。
### 核心职责
1. **结论数据管理**:报告生成时从 `report_conclusions` 模板表复制结论到 `assessment_record_conclusions` 记录级别表,支持管理员针对单条记录手动调整
2. **报告数据服务**:提供内部服务方法,一次性查询指定测评记录的完整报告数据
3. **服务端渲染**:通过 Razor Pages 将报告数据嵌入 HTML 输出,每个报告板块对应独立 URL
4. **页面配置**:通过 `report_page_configs` 表定义 PDF 报告的页面组成和顺序
### 设计决策
- **集成到 MiAssessment.Api**:报告页面面向外部访问(截图服务通过 HTTP 访问),与小程序 API 共用同一个 Web 项目,复用已有的数据库连接和 DI 配置
- **使用 Razor Pages 而非 MVC Views**Razor Pages 是 ASP.NET Core 推荐的页面渲染方式,每个页面自包含(.cshtml + PageModel适合报告这种独立页面场景
- **ECharts CDN 引入**:图表库通过 CDN `<script>` 标签引入,避免增加后端项目的静态资源体积
- **结论数据独立副本**:每条测评记录拥有独立的结论数据,管理员调整不影响模板和其他记录,支持"重新生成"恢复到模板数据
- **新增实体放在 MiAssessment.Model**`AssessmentRecordConclusion` 和 `ReportPageConfig` 实体在 Model 项目中定义,供 Api 和 Admin.Business 共用
- **先搭框架后做页面**:本设计聚焦框架层面(数据表、服务、路由、公共模板),具体每个页面的样式布局后续逐个实现
## 架构
### 整体数据流
```mermaid
sequenceDiagram
participant RGS as ReportGenerationService
participant DB as Business 数据库
participant RDS as ReportDataService
participant RP as Razor Pages
participant SS as 截图服务(外部)
Note over RGS,DB: 报告生成阶段
RGS->>DB: 计算得分/排名/星级 → 写入 assessment_results
RGS->>DB: 从 report_conclusions 复制 → 写入 assessment_record_conclusions
RGS->>DB: 更新 assessment_records.Status = 4
Note over SS,RP: 截图阶段
SS->>DB: 读取 report_page_configs按 SortOrder 排序)
loop 每个 PageType=2 的页面
SS->>RP: GET /report/{page-name}?recordId={id}
RP->>RDS: GetReportDataAsync(recordId)
RDS->>DB: 查询 assessment_records + assessment_results + assessment_record_conclusions
RDS-->>RP: ReportDataDto
RP-->>SS: 渲染完成的 HTML含 data-render-complete 标记)
SS->>SS: 截图 1309×926px
end
SS->>SS: 按 SortOrder 拼装静态图片 + 截图 → PDF
```
### 项目集成方案
```
MiAssessment.Model/
├── Entities/
│ ├── AssessmentRecordConclusion.cs ← 新增实体
│ └── ReportPageConfig.cs ← 新增实体
└── Data/
└── MiAssessmentDbContext.cs ← 新增 DbSet
MiAssessment.Core/
└── Services/
├── ReportGenerationService.cs ← 修改:生成报告时复制结论数据
└── ReportDataService.cs ← 新增:报告数据查询服务
MiAssessment.Api/
├── Pages/ ← 新增 Razor Pages 目录
│ └── Report/
│ ├── _ReportLayout.cshtml ← 报告公共布局
│ ├── Cover.cshtml ← 封面页
│ ├── IntelligenceOverview.cshtml ← 八大智能分析
│ ├── StrongestIntelligence.cshtml ← 最强智能详情
│ ├── WeakestIntelligence.cshtml ← 较弱智能详情
│ ├── PersonalityTraits.cshtml ← 个人特质分析
│ ├── SubAbilities.cshtml ← 40项细分能力
│ ├── LearningTypes.cshtml ← 先天学习类型
│ ├── LearningAbilities.cshtml ← 学习关键能力
│ ├── BrainTypes.cshtml ← 科学大脑类型
│ ├── CharacterTypes.cshtml ← 性格类型
│ └── FutureAbilities.cshtml ← 未来关键发展能力
├── wwwroot/
│ └── css/
│ └── report.css ← 报告公共样式
└── Program.cs ← 添加 Razor Pages 服务注册
```
## 组件与接口
### 1. ReportDataService报告数据服务
位于 `MiAssessment.Core/Services/`,注册为 Scoped 服务。
```csharp
/// <summary>
/// 报告数据服务接口
/// </summary>
public interface IReportDataService
{
/// <summary>
/// 获取指定测评记录的完整报告数据
/// </summary>
/// <param name="recordId">测评记录ID</param>
/// <returns>报告数据传输对象</returns>
/// <exception cref="BusinessException">记录不存在或状态不正确时抛出</exception>
Task<ReportDataDto> GetReportDataAsync(long recordId);
/// <summary>
/// 确保指定测评记录的结论数据存在,不存在则自动生成
/// </summary>
/// <param name="recordId">测评记录ID</param>
Task EnsureConclusionsExistAsync(long recordId);
}
```
### 2. ReportGenerationService 修改
在现有 `PersistResultsAsync` 方法的事务中,增加结论数据复制逻辑:
```csharp
// 在写入 AssessmentResult 之后,复制结论数据
// 1. 删除该 RecordId 已有的 AssessmentRecordConclusion 记录
// 2. 根据每个 FinalCategoryResult 的 CategoryId 和 ConclusionType
// 从 report_conclusions 查询模板,复制 Title 和 Content 到 assessment_record_conclusions
```
### 3. Razor Pages PageModel 基类
```csharp
/// <summary>
/// 报告页面基类,提供公共的 recordId 解析和数据加载逻辑
/// </summary>
public abstract class ReportPageModelBase : PageModel
{
protected readonly IReportDataService ReportDataService;
/// <summary>
/// 报告数据(所有页面共用)
/// </summary>
public ReportDataDto? ReportData { get; set; }
/// <summary>
/// 错误信息(数据异常时设置)
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// 是否渲染成功
/// </summary>
public bool IsSuccess => ErrorMessage == null && ReportData != null;
protected ReportPageModelBase(IReportDataService reportDataService)
{
ReportDataService = reportDataService;
}
/// <summary>
/// 加载报告数据,处理公共的参数验证和异常捕获
/// </summary>
public async Task<IActionResult> OnGetAsync(long? recordId)
{
if (recordId == null || recordId <= 0)
{
ErrorMessage = "缺少测评记录参数";
return Page();
}
try
{
ReportData = await ReportDataService.GetReportDataAsync(recordId.Value);
await OnDataLoadedAsync(); // 子类可覆写,做页面特定的数据处理
return Page();
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
return Page();
}
}
/// <summary>
/// 数据加载完成后的回调,子类可覆写
/// </summary>
protected virtual Task OnDataLoadedAsync() => Task.CompletedTask;
}
```
### 4. 报告页面路由映射
| 路由路径 | Razor Page 文件 | 说明 |
|---------|----------------|------|
| `/report/cover` | `Pages/Report/Cover.cshtml` | 封面页 |
| `/report/intelligence-overview` | `Pages/Report/IntelligenceOverview.cshtml` | 八大智能分析 |
| `/report/strongest-intelligence` | `Pages/Report/StrongestIntelligence.cshtml` | 最强智能详情 |
| `/report/weakest-intelligence` | `Pages/Report/WeakestIntelligence.cshtml` | 较弱智能详情 |
| `/report/personality-traits` | `Pages/Report/PersonalityTraits.cshtml` | 个人特质分析 |
| `/report/sub-abilities` | `Pages/Report/SubAbilities.cshtml` | 40项细分能力 |
| `/report/learning-types` | `Pages/Report/LearningTypes.cshtml` | 先天学习类型 |
| `/report/learning-abilities` | `Pages/Report/LearningAbilities.cshtml` | 学习关键能力 |
| `/report/brain-types` | `Pages/Report/BrainTypes.cshtml` | 科学大脑类型 |
| `/report/character-types` | `Pages/Report/CharacterTypes.cshtml` | 性格类型 |
| `/report/future-abilities` | `Pages/Report/FutureAbilities.cshtml` | 未来关键发展能力 |
Razor Pages 路由通过 `@page "/report/cover"` 指令配置,查询参数 `recordId` 通过 `[BindProperty(SupportsGet = true)]``OnGetAsync` 参数绑定。
### 5. 公共布局模板 `_ReportLayout.cshtml`
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1309" />
<title>@ViewData["Title"] - 测评报告</title>
<link rel="stylesheet" href="/css/report.css" />
@RenderSection("Styles", required: false)
</head>
<body>
<div class="report-page">
<!-- 页面头部:板块标题 -->
@if (ViewData["PageTitle"] != null)
{
<div class="report-header">
<h1 class="report-title">@ViewData["PageTitle"]</h1>
</div>
}
<!-- 页面主体内容 -->
<div class="report-body">
@RenderBody()
</div>
<!-- 页面底部:页码 -->
@if (ViewData["PageNumber"] != null)
{
<div class="report-footer">
<span class="page-number">@ViewData["PageNumber"]</span>
</div>
}
</div>
@RenderSection("Scripts", required: false)
<!-- 渲染完成标记脚本 -->
<script>
// 默认标记渲染完成(无异步内容的页面)
// 有图表的页面在 Scripts section 中覆盖此逻辑
if (!window.__deferRenderComplete) {
document.body.setAttribute('data-render-complete', 'true');
}
</script>
</body>
</html>
```
### 6. 公共样式 `report.css` 核心规则
```css
/* 页面容器:固定 1309×926px */
.report-page {
width: 1309px;
height: 926px;
margin: 0;
padding: 40px 50px;
box-sizing: border-box;
background-color: #FFFFFF;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
position: relative;
overflow: hidden;
}
/* 页面头部 */
.report-header {
margin-bottom: 20px;
}
.report-title {
font-size: 28px;
font-weight: 600;
color: #333333;
}
/* 页面底部页码 */
.report-footer {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
text-align: center;
}
/* 星级显示 */
.star-rating .star-filled { color: #FAAD14; }
.star-rating .star-empty { color: #E8E8E8; }
/* 错误页面 */
.report-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #FF4D4F;
font-size: 20px;
}
/* 主色调 */
:root {
--primary-color: #4A90E2;
--text-color: #333333;
--text-secondary: #666666;
--border-color: #E8E8E8;
--bg-gray: #F8F8F8;
}
```
## 数据模型
### 1. assessment_record_conclusions 表
存储每条测评记录的独立结论数据副本,由报告生成时从 `report_conclusions` 模板表复制而来。
| 字段名 | 类型 | 约束 | 说明 |
|--------|------|------|------|
| Id | bigint | PK, 自增 | 主键ID |
| RecordId | bigint | NOT NULL, FK → assessment_records.Id | 测评记录ID |
| CategoryId | bigint | NOT NULL, FK → report_categories.Id | 分类ID |
| ConclusionType | int | NOT NULL | 结论类型1最强 2较强 3较弱 4最弱 |
| StarLevel | int | NOT NULL | 星级1-5用于记录级别的星级覆盖 |
| Title | nvarchar(100) | NULL | 结论标题 |
| Content | nvarchar(max) | NOT NULL | 结论内容(富文本) |
| CreateTime | datetime | NOT NULL, DEFAULT getdate() | 创建时间 |
| UpdateTime | datetime | NOT NULL, DEFAULT getdate() | 更新时间 |
| IsDeleted | bit | NOT NULL, DEFAULT 0 | 软删除标记 |
索引:
- `ix_arc_record_id` ON (RecordId) — 按测评记录查询
- `ix_arc_record_category` ON (RecordId, CategoryId) — 按记录+分类查询
对应实体类 `AssessmentRecordConclusion`
```csharp
[Table("assessment_record_conclusions")]
public class AssessmentRecordConclusion
{
[Key]
public long Id { get; set; }
public long RecordId { get; set; }
public long CategoryId { get; set; }
/// <summary>
/// 结论类型1最强 2较强 3较弱 4最弱
/// </summary>
public int ConclusionType { get; set; }
/// <summary>
/// 星级1-5记录级别的星级可由管理员覆盖
/// </summary>
public int StarLevel { get; set; }
[MaxLength(100)]
public string? Title { get; set; }
[Required]
[Column(TypeName = "nvarchar(max)")]
public string Content { get; set; } = null!;
public DateTime CreateTime { get; set; }
public DateTime UpdateTime { get; set; }
public bool IsDeleted { get; set; }
[ForeignKey(nameof(RecordId))]
public virtual AssessmentRecord? Record { get; set; }
[ForeignKey(nameof(CategoryId))]
public virtual ReportCategory? Category { get; set; }
}
```
### 2. report_page_configs 表
定义 PDF 报告中每一页的类型、顺序和关联资源。
| 字段名 | 类型 | 约束 | 说明 |
|--------|------|------|------|
| Id | bigint | PK, 自增 | 主键ID |
| PageType | int | NOT NULL | 页面类型1静态图片 2网页截图 |
| PageName | nvarchar(50) | NOT NULL | 页面标识名称(如 cover、intelligence-overview |
| Title | nvarchar(100) | NOT NULL | 页面显示标题 |
| SortOrder | int | NOT NULL | 排序序号,从 1 开始 |
| ImageUrl | nvarchar(500) | NULL | 静态图片路径PageType=1 时必填) |
| RouteUrl | nvarchar(200) | NULL | 网页路由路径PageType=2 时必填) |
| Status | int | NOT NULL, DEFAULT 1 | 状态0禁用 1启用 |
| CreateTime | datetime | NOT NULL, DEFAULT getdate() | 创建时间 |
| UpdateTime | datetime | NOT NULL, DEFAULT getdate() | 更新时间 |
索引:
- `ix_rpc_sort_order` ON (SortOrder) — 按排序查询
- `ix_rpc_status` ON (Status) — 按状态筛选
对应实体类 `ReportPageConfig`
```csharp
[Table("report_page_configs")]
public class ReportPageConfig
{
[Key]
public long Id { get; set; }
/// <summary>
/// 页面类型1静态图片 2网页截图
/// </summary>
public int PageType { get; set; }
/// <summary>
/// 页面标识名称
/// </summary>
[Required]
[MaxLength(50)]
public string PageName { get; set; } = null!;
/// <summary>
/// 页面显示标题
/// </summary>
[Required]
[MaxLength(100)]
public string Title { get; set; } = null!;
/// <summary>
/// 排序序号
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 静态图片路径PageType=1 时使用)
/// </summary>
[MaxLength(500)]
public string? ImageUrl { get; set; }
/// <summary>
/// 网页路由路径PageType=2 时使用)
/// </summary>
[MaxLength(200)]
public string? RouteUrl { get; set; }
/// <summary>
/// 状态0禁用 1启用
/// </summary>
public int Status { get; set; } = 1;
public DateTime CreateTime { get; set; }
public DateTime UpdateTime { get; set; }
}
```
### 3. ReportDataDto报告数据传输对象
```csharp
/// <summary>
/// 报告完整数据 DTO
/// </summary>
public class ReportDataDto
{
/// <summary>
/// 测评记录基本信息
/// </summary>
public RecordInfoDto RecordInfo { get; set; } = null!;
/// <summary>
/// 按 CategoryType 分组的测评结果
/// Key: CategoryType (1-8), Value: 该类型下所有分类的结果列表
/// </summary>
public Dictionary<int, List<CategoryResultDataDto>> ResultsByType { get; set; } = new();
/// <summary>
/// 按 CategoryId 索引的结论数据
/// Key: CategoryId, Value: 该分类的结论数据
/// </summary>
public Dictionary<long, ConclusionDataDto> ConclusionsByCategory { get; set; } = new();
/// <summary>
/// 报告分类层级结构
/// Key: CategoryType, Value: 该类型的分类树
/// </summary>
public Dictionary<int, List<CategoryTreeDto>> CategoryTrees { get; set; } = new();
}
/// <summary>
/// 测评记录基本信息
/// </summary>
public class RecordInfoDto
{
public long RecordId { get; set; }
public string Name { get; set; } = null!;
public int Gender { get; set; }
public int Age { get; set; }
public int EducationStage { get; set; }
public string Province { get; set; } = null!;
public string City { get; set; } = null!;
public string District { get; set; } = null!;
public DateTime? CompleteTime { get; set; }
}
/// <summary>
/// 分类测评结果数据
/// </summary>
public class CategoryResultDataDto
{
public long CategoryId { get; set; }
public string CategoryName { get; set; } = null!;
public string CategoryCode { get; set; } = null!;
public int CategoryType { get; set; }
public long ParentId { get; set; }
public decimal Score { get; set; }
public decimal MaxScore { get; set; }
public decimal Percentage { get; set; }
public int Rank { get; set; }
public int StarLevel { get; set; }
}
/// <summary>
/// 结论数据
/// </summary>
public class ConclusionDataDto
{
public long CategoryId { get; set; }
public int ConclusionType { get; set; }
public int StarLevel { get; set; }
public string? Title { get; set; }
public string Content { get; set; } = null!;
}
/// <summary>
/// 分类树节点
/// </summary>
public class CategoryTreeDto
{
public long Id { get; set; }
public string Name { get; set; } = null!;
public string Code { get; set; } = null!;
public int CategoryType { get; set; }
public long ParentId { get; set; }
public List<CategoryTreeDto> Children { get; set; } = new();
}
```
### 4. 结论数据复制逻辑
`ReportGenerationService.PersistResultsAsync` 事务中增加:
```
对于每个 FinalCategoryResult:
1. conclusionType = MapStarToConclusionType(starLevel)
2. 从 report_conclusions 查询 (CategoryId, conclusionType) 对应的模板
3. 创建 AssessmentRecordConclusion:
- RecordId = 当前记录ID
- CategoryId = result.CategoryId
- ConclusionType = conclusionType
- StarLevel = result.StarLevel
- Title = 模板.Title
- Content = 模板.Content
4. 批量写入 assessment_record_conclusions
```
重新生成逻辑:
```
1. 删除该 RecordId 的所有 AssessmentRecordConclusion硬删除
2. 删除该 RecordId 的所有 AssessmentResult硬删除
3. 重新执行完整的报告生成流水线(计算得分 → 排名 → 星级 → 匹配结论 → 复制结论 → 写入结果)
```
### 5. Program.cs 集成变更
```csharp
// 添加 Razor Pages 支持
builder.Services.AddRazorPages();
// 在 app.MapControllers() 之前添加
app.MapRazorPages();
// 添加静态文件支持(用于 report.css
app.UseStaticFiles();
```
## 正确性属性Correctness Properties
*正确性属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。*
### Property 1: 结论复制数据一致性
*For any* 已完成的测评记录和其对应的 AssessmentResult 列表,执行结论复制后,每条 AssessmentRecordConclusion 的 ConclusionType 应等于 `MapStarToConclusionType(StarLevel)`,且 Title 和 Content 应与 report_conclusions 模板表中对应 (CategoryId, ConclusionType) 的记录完全一致。
**Validates: Requirements 1.1, 1.3**
### Property 2: 重新生成报告的幂等性
*For any* 测评记录,执行"重新生成报告"后产生的 AssessmentResult 和 AssessmentRecordConclusion 数据,应与对同一份原始答案数据执行首次生成的结果完全一致(即手动调整被完全覆盖,恢复到模板状态)。
**Validates: Requirements 1.6, 1.7**
### Property 3: 结论数据自动生成
*For any* 状态为 4已完成的测评记录如果其对应的 AssessmentRecordConclusion 记录不存在,调用 ReportDataService.GetReportDataAsync 后,该记录的 AssessmentRecordConclusion 应被自动创建,且数据与从模板复制的结果一致。
**Validates: Requirements 1.9**
### Property 4: recordId 参数验证
*For any* 报告页面路由,当 URL 中缺少 recordId 参数或 recordId 为空/无效时,返回的 HTML 应包含"缺少测评记录参数"错误提示文本,且 `document.body` 上应有 `data-render-error="true"` 属性。
**Validates: Requirements 2.3, 2.6, 18.1**
### Property 5: 报告数据完整性
*For any* 状态为 4 的测评记录GetReportDataAsync 返回的 ReportDataDto 应包含:非空的 RecordInfo含姓名、性别、年龄、学业阶段、省市区、完成时间以及 CategoryType 1-8 中每个有对应 AssessmentResult 的类型都存在于 ResultsByType 字典中。
**Validates: Requirements 3.2**
### Property 6: 页面配置类型字段约束
*For any* ReportPageConfig 记录,如果 PageType=1 则 ImageUrl 不为空,如果 PageType=2 则 RouteUrl 不为空。
**Validates: Requirements 4.2, 4.3**
### Property 7: 页面配置排序与过滤
*For any* report_page_configs 记录集合,生成 PDF 时使用的页面列表应仅包含 Status=1 的记录,且按 SortOrder 升序排列。
**Validates: Requirements 4.4, 4.5**
### Property 8: 渲染完成信号
*For any* 成功渲染的报告页面(无数据异常),输出的 HTML 中 `document.body` 应包含 `data-render-complete="true"` 属性。
**Validates: Requirements 5.4, 18.3**
### Property 9: 星级图标渲染正确性
*For any* 星级值 n1 ≤ n ≤ 5渲染的星级 HTML 应包含恰好 n 个填充星star-filled和 5-n 个空心星star-empty
**Validates: Requirements 17.4**
### Property 10: 空结论占位文本
*For any* 报告页面中某个分类的 AssessmentRecordConclusion 结论文本为空或不存在时,该位置的渲染输出应包含"暂无分析内容"占位文本,且不影响页面其他内容的正常展示。
**Validates: Requirements 18.2**
## 错误处理
### 数据层错误
| 场景 | 处理方式 |
|------|---------|
| recordId 对应的记录不存在或已软删除 | ReportDataService 抛出 BusinessException("测评记录不存在") |
| 记录状态不为 4已完成 | ReportDataService 抛出 BusinessException("报告尚未生成完成") |
| 结论数据不存在 | 自动触发 EnsureConclusionsExistAsync 生成结论数据 |
| 数据库连接异常 | 记录日志,页面显示通用错误信息 |
### 渲染层错误
| 场景 | 处理方式 |
|------|---------|
| URL 缺少 recordId 参数 | 返回包含"缺少测评记录参数"的错误 HTMLbody 添加 `data-render-error="true"` |
| ReportDataService 抛出异常 | 捕获异常页面显示错误信息body 添加 `data-render-error="true"` |
| 某个板块的结论文本为空 | 对应位置显示"暂无分析内容"占位文本,不影响其他内容 |
| ECharts CDN 加载失败 | 图表区域显示降级文本,仍然标记 `data-render-complete="true"` |
### 页面配置错误
| 场景 | 处理方式 |
|------|---------|
| report_page_configs 表无启用记录 | 截图服务返回错误"报告页面配置为空,无法生成 PDF" |
| PageType=1 但 ImageUrl 为空 | 跳过该页,记录警告日志 |
| PageType=2 但 RouteUrl 为空 | 跳过该页,记录警告日志 |
## 测试策略
### 测试框架
- 单元测试xUnit + Moq
- 属性测试FsCheck.NET 属性测试库)
- 集成测试WebApplicationFactoryASP.NET Core 集成测试)
### 单元测试
重点覆盖以下场景:
1. **ReportDataService**
- GetReportDataAsync 正常返回完整数据
- recordId 不存在时抛出异常
- 记录状态非 4 时抛出异常
- 结论不存在时自动触发生成
2. **结论复制逻辑**
- 星级到结论类型的映射已有测试MapStarToConclusionType
- 复制后数据与模板一致
- 重新生成覆盖手动调整
3. **Razor Pages 渲染**
- 缺少 recordId 时显示错误页面
- 正常数据渲染包含 data-render-complete 属性
- 空结论显示占位文本
4. **页面配置**
- 按 SortOrder 排序
- Status=0 的记录被过滤
- 空配置返回错误
### 属性测试
每个属性测试最少运行 100 次迭代,使用 FsCheck 生成随机输入。
| 属性 | 测试标签 | 说明 |
|------|---------|------|
| Property 1 | Feature: report-web-pages, Property 1: 结论复制数据一致性 | 生成随机星级和分类,验证复制后的 ConclusionType 和内容匹配 |
| Property 2 | Feature: report-web-pages, Property 2: 重新生成报告的幂等性 | 生成随机答案数据,验证两次生成结果一致 |
| Property 5 | Feature: report-web-pages, Property 5: 报告数据完整性 | 生成随机完成记录,验证返回数据包含所有必要字段 |
| Property 6 | Feature: report-web-pages, Property 6: 页面配置类型字段约束 | 生成随机 PageConfig验证 PageType 与对应字段的约束 |
| Property 7 | Feature: report-web-pages, Property 7: 页面配置排序与过滤 | 生成随机配置列表,验证过滤和排序结果 |
| Property 9 | Feature: report-web-pages, Property 9: 星级图标渲染正确性 | 生成随机星级值 1-5验证填充星和空心星数量 |
### 集成测试
使用 `WebApplicationFactory<Program>` 进行端到端测试:
1. 启动测试服务器,访问各报告页面路由
2. 验证 HTTP 200 响应和 HTML 内容
3. 验证错误场景的 HTML 输出
4. 验证 data-render-complete / data-render-error 属性

View File

@ -0,0 +1,237 @@
# 实现计划:测评报告网页版框架搭建
## 概述
本任务聚焦于报告网页版的框架层面搭建数据库表与实体类、数据服务、Razor Pages 基础设施、公共布局模板和样式。具体 11 个报告页面的样式和内容实现不在本次范围内,后续逐个开发。
## 任务
- [x] 1. 新增数据库实体类和 DbContext 配置
- [x] 1.1 创建 AssessmentRecordConclusion 实体类
- 在 `MiAssessment.Model/Entities/` 下新建 `AssessmentRecordConclusion.cs`
- 包含字段Id、RecordId、CategoryId、ConclusionType、StarLevel、Title、Content、CreateTime、UpdateTime、IsDeleted
- 使用 `[Table("assessment_record_conclusions")]` 映射表名
- 添加 ForeignKey 导航属性关联 AssessmentRecord 和 ReportCategory
- 所有属性添加 XML 注释
- _需求: 1.2, 1.5_
- [x] 1.2 创建 ReportPageConfig 实体类
- 在 `MiAssessment.Model/Entities/` 下新建 `ReportPageConfig.cs`
- 包含字段Id、PageType、PageName、Title、SortOrder、ImageUrl、RouteUrl、Status、CreateTime、UpdateTime
- 使用 `[Table("report_page_configs")]` 映射表名
- 所有属性添加 XML 注释
- _需求: 4.1_
- [x] 1.3 在 MiAssessmentDbContext 中注册新 DbSet
- 在 `MiAssessment.Model/Data/MiAssessmentDbContext.cs` 中添加 `DbSet<AssessmentRecordConclusion>``DbSet<ReportPageConfig>`
- 在 `OnModelCreating` 中配置索引:`ix_arc_record_id`、`ix_arc_record_category`、`ix_rpc_sort_order`、`ix_rpc_status`
- _需求: 1.2, 4.1_
- [x] 2. 创建数据库迁移 SQL 脚本
- [x] 2.1 编写 assessment_record_conclusions 建表 SQL
- 在 `temp_sql/` 下创建 SQL 脚本文件
- 包含表结构、索引、默认值约束
- _需求: 1.2_
- [x] 2.2 编写 report_page_configs 建表 SQL 和初始数据
- 建表 SQL 包含表结构、索引、默认值约束
- 插入初始配置数据11 个网页截图页面 + 静态图片页面占位),按 SortOrder 排序
- _需求: 4.1, 4.2, 4.3_
- [x] 3. 检查点 - 确认实体类和数据库脚本
- 确保所有实体类编译通过SQL 脚本语法正确,询问用户是否有问题。
- [x] 4. 创建报告数据 DTO 模型
- [x] 4.1 创建报告数据传输对象
- 在 `MiAssessment.Model/Models/` 下新建 `Report/` 目录
- 创建 `ReportDataDto.cs`,包含 ReportDataDto、RecordInfoDto、CategoryResultDataDto、ConclusionDataDto、CategoryTreeDto
- 所有属性添加 XML 注释
- _需求: 3.1, 3.2_
- [x] 5. 实现 ReportDataService报告数据服务
- [x] 5.1 创建 IReportDataService 接口
- 在 `MiAssessment.Core/Interfaces/` 下新建 `IReportDataService.cs`
- 定义 `GetReportDataAsync(long recordId)``EnsureConclusionsExistAsync(long recordId)` 方法
- _需求: 3.1, 3.5, 1.9_
- [x] 5.2 实现 ReportDataService
- 在 `MiAssessment.Core/Services/` 下新建 `ReportDataService.cs`
- 实现 `GetReportDataAsync`:验证记录存在且状态为 4查询 assessment_records、assessment_results、assessment_record_conclusions、report_categories组装 ReportDataDto
- 实现 `EnsureConclusionsExistAsync`:检查结论是否存在,不存在则从 report_conclusions 模板复制生成
- 记录不存在时抛出 BusinessException("测评记录不存在")
- 状态非 4 时抛出 BusinessException("报告尚未生成完成")
- 结论不存在时自动触发生成
- _需求: 3.1, 3.2, 3.3, 3.4, 3.5, 1.9_
- [ ]* 5.3 编写 ReportDataService 单元测试
- 测试 GetReportDataAsync 正常返回完整数据
- 测试 recordId 不存在时抛出异常
- 测试记录状态非 4 时抛出异常
- 测试结论不存在时自动触发生成
- _需求: 3.3, 3.4, 1.9_
- [x] 6. 修改 ReportGenerationService 增加结论复制逻辑
- [x] 6.1 在 PersistResultsAsync 中添加结论数据复制
- 在现有事务中,写入 AssessmentResult 之后,增加以下逻辑:
- 删除该 RecordId 已有的 AssessmentRecordConclusion 记录(硬删除,支持重新生成)
- 根据每个 FinalCategoryResult 的 StarLevel 调用 MapStarToConclusionType 确定 ConclusionType
- 从 report_conclusions 查询对应 (CategoryId, ConclusionType) 的模板记录
- 复制 Title 和 Content 到新的 AssessmentRecordConclusion设置 RecordId、CategoryId、ConclusionType、StarLevel
- 批量写入 assessment_record_conclusions
- _需求: 1.1, 1.3, 1.6, 1.7_
- [ ]* 6.2 编写结论复制逻辑单元测试
- 测试复制后 ConclusionType 与 MapStarToConclusionType 映射一致
- 测试复制后 Title 和 Content 与模板一致
- 测试重新生成时旧结论被删除
- _需求: 1.1, 1.3, 1.6_
- [ ]* 6.3 编写属性测试:结论复制数据一致性
- **Property 1: 结论复制数据一致性**
- **验证: 需求 1.1, 1.3**
- 生成随机星级和分类,验证复制后的 ConclusionType 和内容与模板匹配
- [ ]* 6.4 编写属性测试:重新生成报告的幂等性
- **Property 2: 重新生成报告的幂等性**
- **验证: 需求 1.6, 1.7**
- 生成随机答案数据,验证两次生成结果完全一致
- [x] 7. 检查点 - 确认数据服务层
- 确保 ReportDataService 和 ReportGenerationService 修改编译通过,所有测试通过,询问用户是否有问题。
- [x] 8. 配置 Razor Pages 基础设施
- [x] 8.1 修改 Program.cs 添加 Razor Pages 支持
- 在 `builder.Services` 中添加 `builder.Services.AddRazorPages()`
- 在 `app.MapControllers()` 之前添加 `app.MapRazorPages()`
- 添加 `app.UseStaticFiles()` 支持静态文件report.css
- _需求: 2.1_
- [x] 8.2 创建报告公共布局 _ReportLayout.cshtml
- 在 `MiAssessment.Api/Pages/Report/` 下创建 `_ReportLayout.cshtml`
- 固定页面尺寸 1309×926px白色背景
- 包含页面头部(板块标题)、主体内容区、底部页码
- 包含 `report.css` 引用
- 包含 Styles 和 Scripts 可选 Section
- 包含渲染完成标记脚本(`data-render-complete="true"`
- 包含错误状态标记逻辑(`data-render-error="true"`
- _需求: 2.1, 5.1, 5.2, 5.3, 5.4, 17.5, 18.1, 18.3_
- [x] 8.3 创建 _ViewImports.cshtml 和 _ViewStart.cshtml
- 在 `MiAssessment.Api/Pages/` 下创建 `_ViewImports.cshtml`,添加 `@using``@addTagHelper` 指令
- 在 `MiAssessment.Api/Pages/Report/` 下创建 `_ViewStart.cshtml`,指定使用 `_ReportLayout` 布局
- _需求: 2.1_
- [x] 8.4 创建 ReportPageModelBase 基类
- 在 `MiAssessment.Api/Pages/Report/` 下创建 `ReportPageModelBase.cs`
- 继承 `PageModel`,注入 `IReportDataService`
- 实现公共的 `OnGetAsync(long? recordId)` 方法:参数验证、数据加载、异常捕获
- 提供 `ReportData`、`ErrorMessage`、`IsSuccess` 属性
- 提供 `OnDataLoadedAsync()` 虚方法供子类覆写
- _需求: 2.3, 2.6, 18.1_
- [x] 9. 创建报告公共样式
- [x] 9.1 创建 report.css 公共样式文件
- 在 `MiAssessment.Api/wwwroot/css/` 下创建 `report.css`
- 页面容器 `.report-page` 固定 1309×926px白色背景overflow: hidden
- 页面头部 `.report-header` 和标题样式
- 页面底部 `.report-footer` 绝对定位页码
- 星级显示 `.star-rating`(填充星 `.star-filled` 金色,空心星 `.star-empty` 灰色)
- 错误页面 `.report-error` 居中显示
- 占位文本 `.no-content` 样式
- CSS 变量定义(主色调、文字颜色、边框颜色等)
- 字体层级按 1309×926px 页面尺寸设计
- _需求: 5.1, 5.2, 5.3, 17.1, 17.2, 17.3, 17.4, 18.2_
- [x] 10. 创建 11 个报告页面骨架
- [x] 10.1 创建封面页骨架 Cover.cshtml + Cover.cshtml.cs
- 路由 `@page "/report/cover"`
- PageModel 继承 ReportPageModelBase
- cshtml 中使用 `_ReportLayout` 布局,显示基本占位内容(标题、测评人信息区域)
- 具体样式和布局后续实现
- _需求: 2.4, 2.5, 6.1, 6.2, 6.3, 6.4_
- [x] 10.2 创建八大智能分析页骨架 IntelligenceOverview.cshtml + IntelligenceOverview.cshtml.cs
- 路由 `@page "/report/intelligence-overview"`
- PageModel 继承 ReportPageModelBase
- cshtml 中显示基本占位内容(雷达图区域、柱状图区域)
- _需求: 2.4, 2.5, 7.1, 7.2, 7.3, 7.4_
- [x] 10.3 创建最强智能详情页骨架 StrongestIntelligence.cshtml + StrongestIntelligence.cshtml.cs
- 路由 `@page "/report/strongest-intelligence"`
- PageModel 继承 ReportPageModelBase
- _需求: 2.4, 2.5, 8.1, 8.4_
- [x] 10.4 创建较弱智能详情页骨架 WeakestIntelligence.cshtml + WeakestIntelligence.cshtml.cs
- 路由 `@page "/report/weakest-intelligence"`
- PageModel 继承 ReportPageModelBase
- _需求: 2.4, 2.5, 9.1, 9.4_
- [x] 10.5 创建个人特质分析页骨架 PersonalityTraits.cshtml + PersonalityTraits.cshtml.cs
- 路由 `@page "/report/personality-traits"`
- PageModel 继承 ReportPageModelBase
- _需求: 2.4, 2.5, 10.1, 10.4_
- [x] 10.6 创建 40 项细分能力分析页骨架 SubAbilities.cshtml + SubAbilities.cshtml.cs
- 路由 `@page "/report/sub-abilities"`
- PageModel 继承 ReportPageModelBase
- _需求: 2.4, 2.5, 11.1, 11.3_
- [x] 10.7 创建先天学习类型分析页骨架 LearningTypes.cshtml + LearningTypes.cshtml.cs
- 路由 `@page "/report/learning-types"`
- PageModel 继承 ReportPageModelBase
- _需求: 2.4, 2.5, 12.1, 12.4_
- [x] 10.8 创建学习关键能力分析页骨架 LearningAbilities.cshtml + LearningAbilities.cshtml.cs
- 路由 `@page "/report/learning-abilities"`
- PageModel 继承 ReportPageModelBase
- _需求: 2.4, 2.5, 13.1, 13.3_
- [x] 10.9 创建科学大脑类型分析页骨架 BrainTypes.cshtml + BrainTypes.cshtml.cs
- 路由 `@page "/report/brain-types"`
- PageModel 继承 ReportPageModelBase
- _需求: 2.4, 2.5, 14.1, 14.4_
- [x] 10.10 创建性格类型分析页骨架 CharacterTypes.cshtml + CharacterTypes.cshtml.cs
- 路由 `@page "/report/character-types"`
- PageModel 继承 ReportPageModelBase
- _需求: 2.4, 2.5, 15.1, 15.4_
- [x] 10.11 创建未来关键发展能力分析页骨架 FutureAbilities.cshtml + FutureAbilities.cshtml.cs
- 路由 `@page "/report/future-abilities"`
- PageModel 继承 ReportPageModelBase
- _需求: 2.4, 2.5, 16.1, 16.3_
- [x] 11. 检查点 - 确认 Razor Pages 基础设施
- 确保所有 Razor Pages 编译通过,路由可访问,公共布局和样式正常加载,询问用户是否有问题。
- [x] 12. 服务注册与集成
- [x] 12.1 注册 ReportDataService 到依赖注入容器
- 在 Autofac ServiceModule 或 Program.cs 中注册 `IReportDataService``ReportDataService`Scoped 生命周期)
- 确保 Razor Pages 的 PageModel 可以通过构造函数注入 IReportDataService
- _需求: 3.5_
- [ ]* 12.2 编写属性测试:报告数据完整性
- **Property 5: 报告数据完整性**
- **验证: 需求 3.2**
- 生成随机完成记录,验证返回数据包含所有必要字段
- [ ]* 12.3 编写属性测试:页面配置类型字段约束
- **Property 6: 页面配置类型字段约束**
- **验证: 需求 4.2, 4.3**
- 生成随机 PageConfig验证 PageType 与对应字段的约束
- [ ]* 12.4 编写属性测试:星级图标渲染正确性
- **Property 9: 星级图标渲染正确性**
- **验证: 需求 17.4**
- 生成随机星级值 1-5验证填充星和空心星数量
- [x] 13. 最终检查点 - 确保所有代码编译通过
- 确保所有测试通过,所有 Razor Pages 路由可正常访问(返回占位内容或错误页面),询问用户是否有问题。
## 备注
- 标记 `*` 的任务为可选任务,可跳过以加快 MVP 进度
- 每个任务引用了具体的需求编号,确保可追溯性
- 检查点用于增量验证,确保每个阶段的代码质量
- 属性测试验证通用正确性属性,单元测试验证具体场景和边界条件
- 本次任务仅搭建框架11 个报告页面的具体样式和内容实现将在后续逐个开发

View File

@ -0,0 +1,19 @@
@page "/report/brain-types"
@model MiAssessment.Api.Pages.Report.BrainTypesModel
@{
ViewData["Title"] = "科学大脑类型分析";
ViewData["PageTitle"] = "科学大脑类型分析";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="brain-types-content">
<p class="no-content">科学大脑类型分析占位 - 具体内容后续实现</p>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 科学大脑类型分析 PageModel
/// </summary>
public class BrainTypesModel : ReportPageModelBase
{
public BrainTypesModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,19 @@
@page "/report/character-types"
@model MiAssessment.Api.Pages.Report.CharacterTypesModel
@{
ViewData["Title"] = "性格类型分析";
ViewData["PageTitle"] = "性格类型分析";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="character-types-content">
<p class="no-content">性格类型分析占位 - 具体内容后续实现</p>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 性格类型分析 PageModel
/// </summary>
public class CharacterTypesModel : ReportPageModelBase
{
public CharacterTypesModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,24 @@
@page "/report/cover"
@model MiAssessment.Api.Pages.Report.CoverModel
@{
ViewData["Title"] = "封面页";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="cover-content">
<h1>多元智能测评报告</h1>
<div class="cover-info">
<p>姓名:@Model.ReportData!.RecordInfo.Name</p>
<p>性别:@(Model.ReportData!.RecordInfo.Gender == 1 ? "男" : "女")</p>
<p>年龄:@Model.ReportData!.RecordInfo.Age</p>
<p>测评日期:@Model.ReportData!.RecordInfo.CompleteTime?.ToString("yyyy年MM月dd日")</p>
</div>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 封面页 PageModel
/// </summary>
public class CoverModel : ReportPageModelBase
{
public CoverModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,19 @@
@page "/report/future-abilities"
@model MiAssessment.Api.Pages.Report.FutureAbilitiesModel
@{
ViewData["Title"] = "未来关键发展能力分析";
ViewData["PageTitle"] = "未来关键发展能力分析";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="future-abilities-content">
<p class="no-content">未来关键发展能力分析占位 - 具体内容后续实现</p>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 未来关键发展能力分析 PageModel
/// </summary>
public class FutureAbilitiesModel : ReportPageModelBase
{
public FutureAbilitiesModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,19 @@
@page "/report/intelligence-overview"
@model MiAssessment.Api.Pages.Report.IntelligenceOverviewModel
@{
ViewData["Title"] = "八大智能分析";
ViewData["PageTitle"] = "八大智能分析";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="intelligence-overview-content">
<p class="no-content">雷达图区域占位、柱状图区域占位 - 具体内容后续实现</p>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 八大智能分析 PageModel
/// </summary>
public class IntelligenceOverviewModel : ReportPageModelBase
{
public IntelligenceOverviewModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,19 @@
@page "/report/learning-abilities"
@model MiAssessment.Api.Pages.Report.LearningAbilitiesModel
@{
ViewData["Title"] = "学习关键能力分析";
ViewData["PageTitle"] = "学习关键能力分析";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="learning-abilities-content">
<p class="no-content">学习关键能力分析占位 - 具体内容后续实现</p>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 学习关键能力分析 PageModel
/// </summary>
public class LearningAbilitiesModel : ReportPageModelBase
{
public LearningAbilitiesModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,19 @@
@page "/report/learning-types"
@model MiAssessment.Api.Pages.Report.LearningTypesModel
@{
ViewData["Title"] = "先天学习类型分析";
ViewData["PageTitle"] = "先天学习类型分析";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="learning-types-content">
<p class="no-content">先天学习类型分析占位 - 具体内容后续实现</p>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 先天学习类型分析 PageModel
/// </summary>
public class LearningTypesModel : ReportPageModelBase
{
public LearningTypesModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,19 @@
@page "/report/personality-traits"
@model MiAssessment.Api.Pages.Report.PersonalityTraitsModel
@{
ViewData["Title"] = "个人特质分析";
ViewData["PageTitle"] = "个人特质分析";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="personality-traits-content">
<p class="no-content">个人特质分析占位 - 具体内容后续实现</p>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 个人特质分析 PageModel
/// </summary>
public class PersonalityTraitsModel : ReportPageModelBase
{
public PersonalityTraitsModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using MiAssessment.Core.Interfaces;
using MiAssessment.Model.Models.Report;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 报告页面基类,提供公共的 recordId 解析和数据加载逻辑
/// </summary>
public abstract class ReportPageModelBase : PageModel
{
protected readonly IReportDataService ReportDataService;
/// <summary>
/// 报告数据(所有页面共用)
/// </summary>
public ReportDataDto? ReportData { get; set; }
/// <summary>
/// 错误信息(数据异常时设置)
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// 是否渲染成功
/// </summary>
public bool IsSuccess => ErrorMessage == null && ReportData != null;
protected ReportPageModelBase(IReportDataService reportDataService)
{
ReportDataService = reportDataService;
}
/// <summary>
/// 加载报告数据,处理公共的参数验证和异常捕获
/// </summary>
public async Task<IActionResult> OnGetAsync(long? recordId)
{
if (recordId == null || recordId <= 0)
{
ErrorMessage = "缺少测评记录参数";
return Page();
}
try
{
ReportData = await ReportDataService.GetReportDataAsync(recordId.Value);
await OnDataLoadedAsync();
return Page();
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
return Page();
}
}
/// <summary>
/// 数据加载完成后的回调,子类可覆写
/// </summary>
protected virtual Task OnDataLoadedAsync() => Task.CompletedTask;
}

View File

@ -0,0 +1,19 @@
@page "/report/strongest-intelligence"
@model MiAssessment.Api.Pages.Report.StrongestIntelligenceModel
@{
ViewData["Title"] = "最强智能详情";
ViewData["PageTitle"] = "最强智能详情";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="strongest-intelligence-content">
<p class="no-content">最强智能详情占位 - 具体内容后续实现</p>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 最强智能详情 PageModel
/// </summary>
public class StrongestIntelligenceModel : ReportPageModelBase
{
public StrongestIntelligenceModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,19 @@
@page "/report/sub-abilities"
@model MiAssessment.Api.Pages.Report.SubAbilitiesModel
@{
ViewData["Title"] = "40项细分能力分析";
ViewData["PageTitle"] = "40项细分能力分析";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="sub-abilities-content">
<p class="no-content">40项细分能力分析占位 - 具体内容后续实现</p>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 40项细分能力分析 PageModel
/// </summary>
public class SubAbilitiesModel : ReportPageModelBase
{
public SubAbilitiesModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,19 @@
@page "/report/weakest-intelligence"
@model MiAssessment.Api.Pages.Report.WeakestIntelligenceModel
@{
ViewData["Title"] = "较弱智能详情";
ViewData["PageTitle"] = "较弱智能详情";
}
@if (!Model.IsSuccess)
{
<div class="report-error" data-render-error="true">
<p>@Model.ErrorMessage</p>
</div>
}
else
{
<div class="weakest-intelligence-content">
<p class="no-content">较弱智能详情占位 - 具体内容后续实现</p>
</div>
}

View File

@ -0,0 +1,14 @@
using MiAssessment.Core.Interfaces;
namespace MiAssessment.Api.Pages.Report;
/// <summary>
/// 较弱智能详情 PageModel
/// </summary>
public class WeakestIntelligenceModel : ReportPageModelBase
{
public WeakestIntelligenceModel(IReportDataService reportDataService)
: base(reportDataService)
{
}
}

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1309" />
<title>@ViewData["Title"] - 测评报告</title>
<link rel="stylesheet" href="/css/report.css" />
@RenderSection("Styles", required: false)
</head>
<body>
<div class="report-page">
<!-- 页面头部:板块标题 -->
@if (ViewData["PageTitle"] != null)
{
<div class="report-header">
<h1 class="report-title">@ViewData["PageTitle"]</h1>
</div>
}
<!-- 页面主体内容 -->
<div class="report-body">
@RenderBody()
</div>
<!-- 页面底部:页码 -->
@if (ViewData["PageNumber"] != null)
{
<div class="report-footer">
<span class="page-number">@ViewData["PageNumber"]</span>
</div>
}
</div>
@RenderSection("Scripts", required: false)
<!-- 渲染完成标记脚本 -->
<script>
// 默认标记渲染完成(无异步内容的页面)
// 有图表的页面在 Scripts section 中覆盖此逻辑
if (!window.__deferRenderComplete) {
document.body.setAttribute('data-render-complete', 'true');
}
</script>
</body>
</html>

View File

@ -0,0 +1,3 @@
@{
Layout = "_ReportLayout";
}

View File

@ -0,0 +1,3 @@
@using MiAssessment.Api
@namespace MiAssessment.Api.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@ -116,6 +116,9 @@ try
// 注册报告生成队列消费者
builder.Services.AddHostedService<ReportQueueConsumer>();
// 添加 Razor Pages 支持(报告网页渲染)
builder.Services.AddRazorPages();
// 添加控制器
builder.Services.AddControllers(options =>
{
@ -185,6 +188,9 @@ try
app.UseCors("Development");
}
// 使用静态文件(报告样式 report.css
app.UseStaticFiles();
// 使用 Serilog 请求日志
app.UseSerilogRequestLogging();
@ -195,6 +201,9 @@ try
app.UseAuthentication();
app.UseAuthorization();
// 映射 Razor Pages报告网页路由
app.MapRazorPages();
// 映射控制器
app.MapControllers();

View File

@ -0,0 +1,150 @@
/* ============================================
测评报告公共样式
页面固定尺寸1309×926px
============================================ */
/* CSS 变量定义 */
:root {
--primary-color: #4A90E2;
--primary-light: #6BA3E8;
--primary-dark: #3A7BC8;
--text-color: #333333;
--text-secondary: #666666;
--text-placeholder: #999999;
--text-disabled: #CCCCCC;
--border-color: #E8E8E8;
--border-light: #F0F0F0;
--bg-gray: #F8F8F8;
--bg-white: #FFFFFF;
--success-color: #52C41A;
--warning-color: #FAAD14;
--error-color: #FF4D4F;
--star-filled: #FAAD14;
--star-empty: #E8E8E8;
}
/* 全局重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* ============================================
页面容器固定 1309×926px
============================================ */
.report-page {
width: 1309px;
height: 926px;
margin: 0;
padding: 40px 50px;
box-sizing: border-box;
background-color: #FFFFFF;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
font-size: 14px;
line-height: 1.6;
color: var(--text-color);
position: relative;
overflow: hidden;
}
/* ============================================
页面头部
============================================ */
.report-header {
margin-bottom: 20px;
}
.report-title {
font-size: 28px;
font-weight: 600;
color: var(--text-color);
line-height: 1.3;
}
/* ============================================
页面主体内容区
============================================ */
.report-body {
flex: 1;
overflow: hidden;
}
/* ============================================
页面底部页码
============================================ */
.report-footer {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
text-align: center;
}
.page-number {
font-size: 12px;
color: var(--text-placeholder);
}
/* ============================================
字体层级 1309×926px 页面尺寸设计
============================================ */
h1 {
font-size: 28px;
font-weight: 600;
line-height: 1.3;
}
h2 {
font-size: 22px;
font-weight: 600;
line-height: 1.4;
}
h3 {
font-size: 18px;
font-weight: 600;
line-height: 1.4;
}
/* 正文 14px小字 12px 已在 .report-page 和 .page-number 中定义 */
/* ============================================
星级显示
使用 Unicode 星号 (filled) (empty)
============================================ */
.star-rating {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 16px;
line-height: 1;
}
.star-filled {
color: var(--star-filled);
}
.star-empty {
color: var(--star-empty);
}
/* ============================================
错误页面
============================================ */
.report-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--error-color);
font-size: 20px;
}
/* ============================================
占位文本
============================================ */
.no-content {
color: var(--text-placeholder);
font-style: italic;
}

View File

@ -0,0 +1,38 @@
using MiAssessment.Model.Models.Report;
namespace MiAssessment.Core.Interfaces;
/// <summary>
/// 报告数据服务接口
/// </summary>
/// <remarks>
/// 提供报告渲染所需的数据查询功能,包括:
/// - 获取指定测评记录的完整报告数据
/// - 确保结论数据存在(不存在则自动从模板生成)
/// </remarks>
public interface IReportDataService
{
/// <summary>
/// 获取指定测评记录的完整报告数据
/// </summary>
/// <remarks>
/// 从数据库查询测评记录、测评结果、记录结论和报告分类数据,
/// 组装为完整的 ReportDataDto 供 Razor Pages 渲染使用。
/// 如果结论数据不存在,会自动触发生成。
/// </remarks>
/// <param name="recordId">测评记录ID</param>
/// <returns>报告数据传输对象</returns>
/// <exception cref="Exception">记录不存在时抛出"测评记录不存在"</exception>
/// <exception cref="Exception">记录状态不为4时抛出"报告尚未生成完成"</exception>
Task<ReportDataDto> GetReportDataAsync(long recordId);
/// <summary>
/// 确保指定测评记录的结论数据存在,不存在则自动生成
/// </summary>
/// <remarks>
/// 检查 assessment_record_conclusions 表中是否存在该记录的结论数据,
/// 如果不存在,则从 report_conclusions 模板表复制生成。
/// </remarks>
/// <param name="recordId">测评记录ID</param>
Task EnsureConclusionsExistAsync(long recordId);
}

View File

@ -0,0 +1,274 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MiAssessment.Core.Interfaces;
using MiAssessment.Model.Data;
using MiAssessment.Model.Entities;
using MiAssessment.Model.Models.Report;
namespace MiAssessment.Core.Services;
/// <summary>
/// 报告数据服务实现
/// </summary>
/// <remarks>
/// 提供报告渲染所需的数据查询功能,包括:
/// - 获取指定测评记录的完整报告数据
/// - 确保结论数据存在(不存在则自动从模板生成)
/// </remarks>
public class ReportDataService : IReportDataService
{
private readonly MiAssessmentDbContext _dbContext;
private readonly ILogger<ReportDataService> _logger;
public ReportDataService(
MiAssessmentDbContext dbContext,
ILogger<ReportDataService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <inheritdoc />
public async Task<ReportDataDto> GetReportDataAsync(long recordId)
{
// 1. 查询测评记录,验证存在且未软删除
var record = await _dbContext.AssessmentRecords
.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == recordId && !r.IsDeleted);
if (record == null)
{
_logger.LogWarning("测评记录不存在recordId: {RecordId}", recordId);
throw new InvalidOperationException("测评记录不存在");
}
// 2. 验证状态为4已完成
if (record.Status != 4)
{
_logger.LogWarning("报告尚未生成完成recordId: {RecordId}, 当前状态: {Status}", recordId, record.Status);
throw new InvalidOperationException("报告尚未生成完成");
}
// 3. 确保结论数据存在
await EnsureConclusionsExistAsync(recordId);
// 4. 查询测评结果,关联报告分类获取 CategoryType
var results = await _dbContext.AssessmentResults
.AsNoTracking()
.Where(r => r.RecordId == recordId)
.Join(
_dbContext.ReportCategories.AsNoTracking().Where(c => !c.IsDeleted),
r => r.CategoryId,
c => c.Id,
(r, c) => new CategoryResultDataDto
{
CategoryId = r.CategoryId,
CategoryName = c.Name,
CategoryCode = c.Code,
CategoryType = c.CategoryType,
ParentId = c.ParentId,
Score = r.Score,
MaxScore = r.MaxScore,
Percentage = r.Percentage,
Rank = r.Rank,
StarLevel = r.StarLevel
})
.ToListAsync();
// 5. 查询记录结论数据
var conclusions = await _dbContext.AssessmentRecordConclusions
.AsNoTracking()
.Where(c => c.RecordId == recordId && !c.IsDeleted)
.Select(c => new ConclusionDataDto
{
CategoryId = c.CategoryId,
ConclusionType = c.ConclusionType,
StarLevel = c.StarLevel,
Title = c.Title,
Content = c.Content
})
.ToListAsync();
// 6. 查询报告分类,构建分类树
var categories = await _dbContext.ReportCategories
.AsNoTracking()
.Where(c => !c.IsDeleted)
.OrderBy(c => c.Sort)
.ToListAsync();
// 7. 组装 ReportDataDto
var reportData = new ReportDataDto
{
RecordInfo = new RecordInfoDto
{
RecordId = record.Id,
Name = record.Name,
Gender = record.Gender,
Age = record.Age,
EducationStage = record.EducationStage,
Province = record.Province,
City = record.City,
District = record.District,
CompleteTime = record.CompleteTime
},
// 按 CategoryType 分组测评结果
ResultsByType = results
.GroupBy(r => r.CategoryType)
.ToDictionary(g => g.Key, g => g.ToList()),
// 按 CategoryId 索引结论数据
ConclusionsByCategory = conclusions
.ToDictionary(c => c.CategoryId, c => c),
// 构建分类树
CategoryTrees = BuildCategoryTrees(categories)
};
_logger.LogDebug("报告数据加载完成recordId: {RecordId}, 结果数: {ResultCount}, 结论数: {ConclusionCount}",
recordId, results.Count, conclusions.Count);
return reportData;
}
/// <inheritdoc />
public async Task EnsureConclusionsExistAsync(long recordId)
{
// 1. 检查是否已存在结论数据
var hasConclusions = await _dbContext.AssessmentRecordConclusions
.AnyAsync(c => c.RecordId == recordId && !c.IsDeleted);
if (hasConclusions)
{
_logger.LogDebug("结论数据已存在跳过生成recordId: {RecordId}", recordId);
return;
}
_logger.LogInformation("结论数据不存在开始自动生成recordId: {RecordId}", recordId);
// 2. 查询该记录的测评结果
var assessmentResults = await _dbContext.AssessmentResults
.Where(r => r.RecordId == recordId)
.ToListAsync();
if (assessmentResults.Count == 0)
{
_logger.LogWarning("测评结果为空无法生成结论recordId: {RecordId}", recordId);
return;
}
// 3. 根据星级确定结论类型,查询模板并复制
var now = DateTime.Now;
var newConclusions = new List<AssessmentRecordConclusion>();
foreach (var result in assessmentResults)
{
var conclusionType = MapStarToConclusionType(result.StarLevel);
// 从 report_conclusions 模板表查询对应结论
var template = await _dbContext.ReportConclusions
.AsNoTracking()
.FirstOrDefaultAsync(t =>
t.CategoryId == result.CategoryId &&
t.ConclusionType == conclusionType &&
!t.IsDeleted);
if (template == null)
{
_logger.LogWarning("未找到结论模板categoryId: {CategoryId}, conclusionType: {ConclusionType}",
result.CategoryId, conclusionType);
continue;
}
// 复制模板数据到记录级别结论
newConclusions.Add(new AssessmentRecordConclusion
{
RecordId = recordId,
CategoryId = result.CategoryId,
ConclusionType = conclusionType,
StarLevel = result.StarLevel,
Title = template.Title,
Content = template.Content,
CreateTime = now,
UpdateTime = now,
IsDeleted = false
});
}
if (newConclusions.Count > 0)
{
await _dbContext.AssessmentRecordConclusions.AddRangeAsync(newConclusions);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("结论数据生成完成recordId: {RecordId}, 生成数量: {Count}",
recordId, newConclusions.Count);
}
}
/// <summary>
/// 星级映射到结论类型
/// </summary>
/// <remarks>
/// 5星→1(最强), 4星→2(较强), 3星→2(较强), 2星→3(较弱), 1星→4(最弱)
/// </remarks>
/// <param name="starLevel">星级1-5</param>
/// <returns>结论类型1-4</returns>
private static int MapStarToConclusionType(int starLevel)
{
return starLevel switch
{
5 => 1, // 最强
4 => 2, // 较强
3 => 2, // 较强
2 => 3, // 较弱
1 => 4, // 最弱
_ => 2 // 默认较强
};
}
/// <summary>
/// 构建分类树结构
/// </summary>
/// <param name="categories">所有分类列表</param>
/// <returns>按 CategoryType 分组的分类树</returns>
private static Dictionary<int, List<CategoryTreeDto>> BuildCategoryTrees(List<ReportCategory> categories)
{
var result = new Dictionary<int, List<CategoryTreeDto>>();
// 按 CategoryType 分组
var grouped = categories.GroupBy(c => c.CategoryType);
foreach (var group in grouped)
{
var categoryType = group.Key;
var items = group.ToList();
// 找出顶级分类ParentId == 0
var roots = items
.Where(c => c.ParentId == 0)
.Select(c => BuildTreeNode(c, items))
.ToList();
result[categoryType] = roots;
}
return result;
}
/// <summary>
/// 递归构建分类树节点
/// </summary>
private static CategoryTreeDto BuildTreeNode(ReportCategory category, List<ReportCategory> allCategories)
{
var node = new CategoryTreeDto
{
Id = category.Id,
Name = category.Name,
Code = category.Code,
CategoryType = category.CategoryType,
ParentId = category.ParentId,
Children = allCategories
.Where(c => c.ParentId == category.Id)
.Select(c => BuildTreeNode(c, allCategories))
.ToList()
};
return node;
}
}

View File

@ -440,6 +440,8 @@ public class ReportGenerationService
/// 事务内执行以下操作:
/// - 删除该 RecordId 已有的 AssessmentResult 记录(支持重新生成)
/// - 批量写入新的 AssessmentResult 记录
/// - 删除该 RecordId 已有的 AssessmentRecordConclusion 记录(硬删除,支持重新生成)
/// - 从 report_conclusions 模板表复制结论数据到 assessment_record_conclusions
/// - 更新 AssessmentRecord 的 Status 为 4已完成并设置 CompleteTime
/// 异常时回滚事务Status 保持为 3生成中
/// </remarks>
@ -478,6 +480,68 @@ public class ReportGenerationService
await _dbContext.AssessmentResults.AddRangeAsync(newResults);
// 删除该 RecordId 已有的结论记录(硬删除,支持重新生成)
var existingConclusions = await _dbContext.AssessmentRecordConclusions
.Where(c => c.RecordId == recordId)
.ToListAsync();
if (existingConclusions.Count > 0)
{
_dbContext.AssessmentRecordConclusions.RemoveRange(existingConclusions);
_logger.LogDebug("删除已有结论记录recordId: {RecordId}, 数量: {Count}", recordId, existingConclusions.Count);
}
// 收集所有需要查询的 (CategoryId, ConclusionType) 组合
var categoryConclusions = finalResults
.Select(r => new { r.CategoryId, r.ConclusionType })
.ToList();
var categoryIds = categoryConclusions.Select(c => c.CategoryId).Distinct().ToList();
var conclusionTypes = categoryConclusions.Select(c => c.ConclusionType).Distinct().ToList();
// 从 report_conclusions 模板表查询匹配的结论记录
var templates = await _dbContext.ReportConclusions
.AsNoTracking()
.Where(c => !c.IsDeleted && categoryIds.Contains(c.CategoryId) && conclusionTypes.Contains(c.ConclusionType))
.ToListAsync();
// 构建 (CategoryId, ConclusionType) → (Title, Content) 的快速查找字典
var templateMap = templates
.GroupBy(c => (c.CategoryId, c.ConclusionType))
.ToDictionary(g => g.Key, g => g.First());
// 复制结论数据到 assessment_record_conclusions
var newConclusions = new List<AssessmentRecordConclusion>();
foreach (var result in finalResults)
{
if (templateMap.TryGetValue((result.CategoryId, result.ConclusionType), out var template))
{
newConclusions.Add(new AssessmentRecordConclusion
{
RecordId = recordId,
CategoryId = result.CategoryId,
ConclusionType = result.ConclusionType,
StarLevel = result.StarLevel,
Title = template.Title,
Content = template.Content,
CreateTime = now,
UpdateTime = now,
IsDeleted = false
});
}
else
{
_logger.LogWarning("未找到结论模板recordId: {RecordId}, categoryId: {CategoryId}, conclusionType: {ConclusionType}",
recordId, result.CategoryId, result.ConclusionType);
}
}
if (newConclusions.Count > 0)
{
await _dbContext.AssessmentRecordConclusions.AddRangeAsync(newConclusions);
_logger.LogDebug("复制结论数据recordId: {RecordId}, 数量: {Count}", recordId, newConclusions.Count);
}
// 重新加载测评记录带跟踪更新状态为4已完成
var record = await _dbContext.AssessmentRecords
.FirstAsync(r => r.Id == recordId);
@ -489,7 +553,8 @@ public class ReportGenerationService
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
_logger.LogInformation("结果持久化完成recordId: {RecordId}, 写入记录数: {Count}", recordId, newResults.Count);
_logger.LogInformation("结果持久化完成recordId: {RecordId}, 写入结果数: {ResultCount}, 写入结论数: {ConclusionCount}",
recordId, newResults.Count, newConclusions.Count);
}
catch (Exception ex)
{

View File

@ -134,6 +134,16 @@ public class ServiceModule : Module
// 注册报告队列生产者
builder.RegisterType<ReportQueueProducer>().As<IReportQueueProducer>().InstancePerLifetimeScope();
// ========== 报告数据服务注册 ==========
// 注册报告数据服务(供 Razor Pages 报告渲染使用)
builder.Register(c =>
{
var dbContext = c.Resolve<MiAssessmentDbContext>();
var logger = c.Resolve<ILogger<ReportDataService>>();
return new ReportDataService(dbContext, logger);
}).As<IReportDataService>().InstancePerLifetimeScope();
// ========== 小程序测评模块服务注册 ==========
// 注册报告生成服务

View File

@ -67,6 +67,11 @@ public partial class MiAssessmentDbContext : DbContext
public virtual DbSet<HomeNavigation> HomeNavigations { get; set; }
// ==================== 报告网页版相关表 ====================
public virtual DbSet<AssessmentRecordConclusion> AssessmentRecordConclusions { get; set; }
public virtual DbSet<ReportPageConfig> ReportPageConfigs { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// Connection string is configured in Program.cs via dependency injection
@ -1052,6 +1057,81 @@ public partial class MiAssessmentDbContext : DbContext
.HasComment("软删除标记");
});
// ==================== 测评记录结论表配置 ====================
modelBuilder.Entity<AssessmentRecordConclusion>(entity =>
{
entity.HasKey(e => e.Id).HasName("pk_assessment_record_conclusions");
entity.ToTable("assessment_record_conclusions", tb => tb.HasComment("测评记录结论表,存储每条测评记录的独立结论数据副本"));
entity.HasIndex(e => e.RecordId).HasDatabaseName("ix_arc_record_id");
entity.HasIndex(e => new { e.RecordId, e.CategoryId }).HasDatabaseName("ix_arc_record_category");
entity.Property(e => e.Id)
.HasComment("主键ID");
entity.Property(e => e.RecordId)
.HasComment("测评记录ID");
entity.Property(e => e.CategoryId)
.HasComment("分类ID");
entity.Property(e => e.ConclusionType)
.HasComment("结论类型1最强 2较强 3较弱 4最弱");
entity.Property(e => e.StarLevel)
.HasComment("星级1-5记录级别的星级可由管理员覆盖");
entity.Property(e => e.Title)
.HasMaxLength(100)
.HasComment("结论标题");
entity.Property(e => e.Content)
.HasComment("结论内容(富文本)");
entity.Property(e => e.CreateTime)
.HasDefaultValueSql("(getdate())")
.HasComment("创建时间");
entity.Property(e => e.UpdateTime)
.HasDefaultValueSql("(getdate())")
.HasComment("更新时间");
entity.Property(e => e.IsDeleted)
.HasDefaultValue(false)
.HasComment("软删除标记");
});
// ==================== 报告页面配置表配置 ====================
modelBuilder.Entity<ReportPageConfig>(entity =>
{
entity.HasKey(e => e.Id).HasName("pk_report_page_configs");
entity.ToTable("report_page_configs", tb => tb.HasComment("报告页面配置表定义PDF报告中每一页的类型和顺序"));
entity.HasIndex(e => e.SortOrder).HasDatabaseName("ix_rpc_sort_order");
entity.HasIndex(e => e.Status).HasDatabaseName("ix_rpc_status");
entity.Property(e => e.Id)
.HasComment("主键ID");
entity.Property(e => e.PageType)
.HasComment("页面类型1静态图片 2网页截图");
entity.Property(e => e.PageName)
.HasMaxLength(50)
.HasComment("页面标识名称");
entity.Property(e => e.Title)
.HasMaxLength(100)
.HasComment("页面显示标题");
entity.Property(e => e.SortOrder)
.HasComment("排序序号");
entity.Property(e => e.ImageUrl)
.HasMaxLength(500)
.HasComment("静态图片路径PageType=1时使用");
entity.Property(e => e.RouteUrl)
.HasMaxLength(200)
.HasComment("网页路由路径PageType=2时使用");
entity.Property(e => e.Status)
.HasDefaultValue(1)
.HasComment("状态0禁用 1启用");
entity.Property(e => e.CreateTime)
.HasDefaultValueSql("(getdate())")
.HasComment("创建时间");
entity.Property(e => e.UpdateTime)
.HasDefaultValueSql("(getdate())")
.HasComment("更新时间");
});
OnModelCreatingPartial(modelBuilder);
}

View File

@ -0,0 +1,78 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MiAssessment.Model.Entities;
/// <summary>
/// 测评记录结论表
/// </summary>
[Table("assessment_record_conclusions")]
public class AssessmentRecordConclusion
{
/// <summary>
/// 主键ID
/// </summary>
[Key]
public long Id { get; set; }
/// <summary>
/// 测评记录ID
/// </summary>
public long RecordId { get; set; }
/// <summary>
/// 分类ID
/// </summary>
public long CategoryId { get; set; }
/// <summary>
/// 结论类型1最强 2较强 3较弱 4最弱
/// </summary>
public int ConclusionType { get; set; }
/// <summary>
/// 星级1-5记录级别的星级可由管理员覆盖
/// </summary>
public int StarLevel { get; set; }
/// <summary>
/// 结论标题
/// </summary>
[MaxLength(100)]
public string? Title { get; set; }
/// <summary>
/// 结论内容(富文本)
/// </summary>
[Required]
[Column(TypeName = "nvarchar(max)")]
public string Content { get; set; } = null!;
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 更新时间
/// </summary>
public DateTime UpdateTime { get; set; }
/// <summary>
/// 软删除标记
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// 关联的测评记录
/// </summary>
[ForeignKey(nameof(RecordId))]
public virtual AssessmentRecord? Record { get; set; }
/// <summary>
/// 关联的报告分类
/// </summary>
[ForeignKey(nameof(CategoryId))]
public virtual ReportCategory? Category { get; set; }
}

View File

@ -0,0 +1,69 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MiAssessment.Model.Entities;
/// <summary>
/// 报告页面配置表
/// </summary>
[Table("report_page_configs")]
public class ReportPageConfig
{
/// <summary>
/// 主键ID
/// </summary>
[Key]
public long Id { get; set; }
/// <summary>
/// 页面类型1静态图片 2网页截图
/// </summary>
public int PageType { get; set; }
/// <summary>
/// 页面标识名称
/// </summary>
[Required]
[MaxLength(50)]
public string PageName { get; set; } = null!;
/// <summary>
/// 页面显示标题
/// </summary>
[Required]
[MaxLength(100)]
public string Title { get; set; } = null!;
/// <summary>
/// 排序序号
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 静态图片路径PageType=1 时使用)
/// </summary>
[MaxLength(500)]
public string? ImageUrl { get; set; }
/// <summary>
/// 网页路由路径PageType=2 时使用)
/// </summary>
[MaxLength(200)]
public string? RouteUrl { get; set; }
/// <summary>
/// 状态0禁用 1启用
/// </summary>
public int Status { get; set; } = 1;
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 更新时间
/// </summary>
public DateTime UpdateTime { get; set; }
}

View File

@ -0,0 +1,204 @@
namespace MiAssessment.Model.Models.Report;
/// <summary>
/// 报告完整数据 DTO
/// </summary>
public class ReportDataDto
{
/// <summary>
/// 测评记录基本信息
/// </summary>
public RecordInfoDto RecordInfo { get; set; } = null!;
/// <summary>
/// 按 CategoryType 分组的测评结果
/// Key: CategoryType (1-8), Value: 该类型下所有分类的结果列表
/// </summary>
public Dictionary<int, List<CategoryResultDataDto>> ResultsByType { get; set; } = new();
/// <summary>
/// 按 CategoryId 索引的结论数据
/// Key: CategoryId, Value: 该分类的结论数据
/// </summary>
public Dictionary<long, ConclusionDataDto> ConclusionsByCategory { get; set; } = new();
/// <summary>
/// 报告分类层级结构
/// Key: CategoryType, Value: 该类型的分类树
/// </summary>
public Dictionary<int, List<CategoryTreeDto>> CategoryTrees { get; set; } = new();
}
/// <summary>
/// 测评记录基本信息
/// </summary>
public class RecordInfoDto
{
/// <summary>
/// 测评记录ID
/// </summary>
public long RecordId { get; set; }
/// <summary>
/// 测评人姓名
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// 性别1男 2女
/// </summary>
public int Gender { get; set; }
/// <summary>
/// 年龄
/// </summary>
public int Age { get; set; }
/// <summary>
/// 学业阶段
/// </summary>
public int EducationStage { get; set; }
/// <summary>
/// 省份
/// </summary>
public string Province { get; set; } = null!;
/// <summary>
/// 城市
/// </summary>
public string City { get; set; } = null!;
/// <summary>
/// 区县
/// </summary>
public string District { get; set; } = null!;
/// <summary>
/// 测评完成时间
/// </summary>
public DateTime? CompleteTime { get; set; }
}
/// <summary>
/// 分类测评结果数据
/// </summary>
public class CategoryResultDataDto
{
/// <summary>
/// 分类ID
/// </summary>
public long CategoryId { get; set; }
/// <summary>
/// 分类名称
/// </summary>
public string CategoryName { get; set; } = null!;
/// <summary>
/// 分类编码
/// </summary>
public string CategoryCode { get; set; } = null!;
/// <summary>
/// 分类类型1-8
/// </summary>
public int CategoryType { get; set; }
/// <summary>
/// 父分类ID
/// </summary>
public long ParentId { get; set; }
/// <summary>
/// 得分
/// </summary>
public decimal Score { get; set; }
/// <summary>
/// 满分
/// </summary>
public decimal MaxScore { get; set; }
/// <summary>
/// 百分比
/// </summary>
public decimal Percentage { get; set; }
/// <summary>
/// 排名
/// </summary>
public int Rank { get; set; }
/// <summary>
/// 星级1-5
/// </summary>
public int StarLevel { get; set; }
}
/// <summary>
/// 结论数据
/// </summary>
public class ConclusionDataDto
{
/// <summary>
/// 分类ID
/// </summary>
public long CategoryId { get; set; }
/// <summary>
/// 结论类型1最强 2较强 3较弱 4最弱
/// </summary>
public int ConclusionType { get; set; }
/// <summary>
/// 星级1-5
/// </summary>
public int StarLevel { get; set; }
/// <summary>
/// 结论标题
/// </summary>
public string? Title { get; set; }
/// <summary>
/// 结论内容(富文本)
/// </summary>
public string Content { get; set; } = null!;
}
/// <summary>
/// 分类树节点
/// </summary>
public class CategoryTreeDto
{
/// <summary>
/// 分类ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// 分类名称
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// 分类编码
/// </summary>
public string Code { get; set; } = null!;
/// <summary>
/// 分类类型1-8
/// </summary>
public int CategoryType { get; set; }
/// <summary>
/// 父分类ID
/// </summary>
public long ParentId { get; set; }
/// <summary>
/// 子分类列表
/// </summary>
public List<CategoryTreeDto> Children { get; set; } = new();
}

View File

@ -0,0 +1,144 @@
-- ============================================================
-- 创建 assessment_record_conclusions 表
-- 存储每条测评记录的独立结论数据副本
-- 由报告生成时从 report_conclusions 模板表复制而来
-- Database: MiAssessment_Business (SQL Server 2022)
-- ============================================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'assessment_record_conclusions')
BEGIN
CREATE TABLE assessment_record_conclusions (
Id bigint IDENTITY(1,1) NOT NULL,
RecordId bigint NOT NULL,
CategoryId bigint NOT NULL,
ConclusionType int NOT NULL,
StarLevel int NOT NULL,
Title nvarchar(100) NULL,
Content nvarchar(max) NOT NULL,
CreateTime datetime NOT NULL CONSTRAINT DF_arc_CreateTime DEFAULT (GETDATE()),
UpdateTime datetime NOT NULL CONSTRAINT DF_arc_UpdateTime DEFAULT (GETDATE()),
IsDeleted bit NOT NULL CONSTRAINT DF_arc_IsDeleted DEFAULT (0),
CONSTRAINT PK_assessment_record_conclusions PRIMARY KEY CLUSTERED (Id),
CONSTRAINT FK_arc_assessment_records FOREIGN KEY (RecordId) REFERENCES assessment_records (Id),
CONSTRAINT FK_arc_report_categories FOREIGN KEY (CategoryId) REFERENCES report_categories (Id)
);
-- 按测评记录查询
CREATE NONCLUSTERED INDEX ix_arc_record_id
ON assessment_record_conclusions (RecordId);
-- 按记录+分类查询
CREATE NONCLUSTERED INDEX ix_arc_record_category
ON assessment_record_conclusions (RecordId, CategoryId);
PRINT N'表 assessment_record_conclusions 创建成功';
END
ELSE
BEGIN
PRINT N'表 assessment_record_conclusions 已存在,跳过创建';
END
GO
-- ============================================================
-- 创建 report_page_configs 表
-- 定义 PDF 报告中每一页的类型、顺序和关联资源
-- Database: MiAssessment_Business (SQL Server 2022)
-- ============================================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'report_page_configs')
BEGIN
CREATE TABLE report_page_configs (
Id bigint IDENTITY(1,1) NOT NULL,
PageType int NOT NULL,
PageName nvarchar(50) NOT NULL,
Title nvarchar(100) NOT NULL,
SortOrder int NOT NULL,
ImageUrl nvarchar(500) NULL,
RouteUrl nvarchar(200) NULL,
Status int NOT NULL CONSTRAINT DF_rpc_Status DEFAULT (1),
CreateTime datetime NOT NULL CONSTRAINT DF_rpc_CreateTime DEFAULT (GETDATE()),
UpdateTime datetime NOT NULL CONSTRAINT DF_rpc_UpdateTime DEFAULT (GETDATE()),
CONSTRAINT PK_report_page_configs PRIMARY KEY CLUSTERED (Id)
);
-- 按排序查询
CREATE NONCLUSTERED INDEX ix_rpc_sort_order
ON report_page_configs (SortOrder);
-- 按状态筛选
CREATE NONCLUSTERED INDEX ix_rpc_status
ON report_page_configs (Status);
PRINT N'表 report_page_configs 创建成功';
END
ELSE
BEGIN
PRINT N'表 report_page_configs 已存在,跳过创建';
END
GO
-- ============================================================
-- 插入 report_page_configs 初始数据
-- 包含静态图片页PageType=1和网页截图页PageType=2
-- 按 SortOrder 排序
-- ============================================================
-- 仅在表为空时插入初始数据
IF NOT EXISTS (SELECT 1 FROM report_page_configs)
BEGIN
INSERT INTO report_page_configs (PageType, PageName, Title, SortOrder, ImageUrl, RouteUrl, Status)
VALUES
-- 静态图片页:报告封面图
(1, N'cover-image', N'报告封面图', 1, N'/images/report/cover.png', NULL, 1),
-- 网页截图页:封面页(测评人信息)
(2, N'cover', N'封面页', 2, NULL, N'/report/cover'),
-- 静态图片页:目录/导读页
(1, N'intro-page', N'目录导读页', 3, N'/images/report/intro.png', NULL, 1),
-- 网页截图页:八大智能分析
(2, N'intelligence-overview', N'八大智能分析', 4, NULL, N'/report/intelligence-overview'),
-- 网页截图页:最强智能详情
(2, N'strongest-intelligence', N'最强智能详情', 5, NULL, N'/report/strongest-intelligence'),
-- 网页截图页:较弱智能详情
(2, N'weakest-intelligence', N'较弱智能详情', 6, NULL, N'/report/weakest-intelligence'),
-- 网页截图页:个人特质分析
(2, N'personality-traits', N'个人特质分析', 7, NULL, N'/report/personality-traits'),
-- 网页截图页40项细分能力分析
(2, N'sub-abilities', N'40项细分能力分析', 8, NULL, N'/report/sub-abilities'),
-- 静态图片页:板块分隔页
(1, N'separator-learning', N'学习分析分隔页', 9, N'/images/report/separator-learning.png', NULL, 1),
-- 网页截图页:先天学习类型分析
(2, N'learning-types', N'先天学习类型分析', 10, NULL, N'/report/learning-types'),
-- 网页截图页:学习关键能力分析
(2, N'learning-abilities', N'学习关键能力分析', 11, NULL, N'/report/learning-abilities'),
-- 网页截图页:科学大脑类型分析
(2, N'brain-types', N'科学大脑类型分析', 12, NULL, N'/report/brain-types'),
-- 网页截图页:性格类型分析
(2, N'character-types', N'性格类型分析', 13, NULL, N'/report/character-types'),
-- 网页截图页:未来关键发展能力分析
(2, N'future-abilities', N'未来关键发展能力分析', 14, NULL, N'/report/future-abilities'),
-- 静态图片页:报告尾页
(1, N'ending-page', N'报告尾页', 15, N'/images/report/ending.png', NULL, 1);
PRINT N'report_page_configs 初始数据插入成功(共 15 条)';
END
ELSE
BEGIN
PRINT N'report_page_configs 已有数据,跳过初始数据插入';
END
GO