exam_service

This commit is contained in:
SpecialX
2025-06-11 15:02:20 +08:00
parent 97843ab5fd
commit e26881ec2f
52 changed files with 3510 additions and 1174 deletions

View File

@@ -15,10 +15,9 @@ namespace Entities.Contracts
[Column("id")] [Column("id")]
public Guid Id { get; set; } public Guid Id { get; set; }
[Required]
[Column("assignment")] [Column("assignment")]
[ForeignKey("Assignment")] [ForeignKey("Assignment")]
public Guid AssignmentId { get; set; } public Guid? AssignmentId { get; set; }
[Required] [Required]
[Column("title")] [Column("title")]
@@ -31,7 +30,7 @@ namespace Entities.Contracts
[Column("total_points")] [Column("total_points")]
public decimal? TotalPoints { get; set; } public float? TotalPoints { get; set; }
[Column("number")] [Column("number")]
public byte Number { get; set; } public byte Number { get; set; }
@@ -42,9 +41,12 @@ namespace Entities.Contracts
[Column("deleted")] [Column("deleted")]
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }
[Column("valid_question_group")]
public bool ValidQuestionGroup { get; set; }
// Navigation Properties // Navigation Properties
public Assignment Assignment { get; set; } public Assignment? Assignment { get; set; }
public AssignmentGroup ParentAssignmentGroup { get; set;} public AssignmentGroup? ParentAssignmentGroup { get; set;}
public ICollection<AssignmentGroup> ChildAssignmentGroups { get; set; } public ICollection<AssignmentGroup> ChildAssignmentGroups { get; set; }
public ICollection<AssignmentQuestion> AssignmentQuestions { get; set; } public ICollection<AssignmentQuestion> AssignmentQuestions { get; set; }

View File

@@ -21,21 +21,23 @@ namespace Entities.Contracts
[ForeignKey("Question")] [ForeignKey("Question")]
public Guid QuestionId { get; set; } public Guid QuestionId { get; set; }
[Required]
[Column("group_id")]
[ForeignKey("AssignmentGroup")]
public Guid AssignmentGroupId { get; set; }
[Required] [Required]
[Column("question_number")] [Column("question_number")]
public byte QuestionNumber { get; set; } public byte QuestionNumber { get; set; }
[Column("created_at")] [Column("created_at")]
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
[Column("score")] [Column("score")]
public float? Score { get; set; } public float? Score { get; set; }
[Required]
[Column("detail_id")]
[ForeignKey("AssignmentGroup")]
public Guid AssignmentGroupId { get; set; }
[Column("deleted")] [Column("deleted")]
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }

View File

@@ -50,6 +50,9 @@ namespace Entities.Contracts
[Column("deleted")] [Column("deleted")]
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }
[Column("valid_question")]
public bool ValidQuestion { get; set; }
// Navigation Properties // Navigation Properties
public User Creator { get; set; } public User Creator { get; set; }
public ICollection<AssignmentQuestion> AssignmentQuestions { get; set; } public ICollection<AssignmentQuestion> AssignmentQuestions { get; set; }

View File

@@ -3,21 +3,47 @@
public class ApiResponse public class ApiResponse
{ {
public ApiResponse(string message, bool status = false) public ApiResponse(string message, bool status = false)
{ : this(status, message, null) { }
this.Message = message;
this.Status = status;
}
public ApiResponse(bool status, object result) public ApiResponse(bool status, object result)
: this(status, string.Empty, result) { }
/// <summary>
/// 创建一个表示成功响应的 ApiResponse 实例。
/// </summary>
/// <param name="message">成功消息。</param>
/// <param name="result">可选的返回数据。</param>
/// <returns>ApiResponse 实例。</returns>
public static ApiResponse Success(string message = "操作成功。", object? result = null)
{ {
this.Status = status; return new ApiResponse(true, message, result);
this.Result = result; }
/// <summary>
/// 创建一个表示失败响应的 ApiResponse 实例。
/// </summary>
/// <param name="message">错误消息。</param>
/// <param name="result">可选的错误详情或数据。</param>
/// <returns>ApiResponse 实例。</returns>
public static ApiResponse Error(string message = "操作失败。", object? result = null)
{
return new ApiResponse(false, message, result);
} }
public ApiResponse() public ApiResponse()
{ {
} }
private ApiResponse(bool status, string message, object? result)
{
Status = status;
Message = message;
Result = result;
}
public string Message { get; set; } public string Message { get; set; }
public bool Status { get; set; } public bool Status { get; set; }

View File

@@ -3,31 +3,33 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Xml.Serialization;
namespace Entities.DTO namespace Entities.DTO
{ {
public class ExamDto public class ExamDto
{ {
public Guid? AssignmentId { get; set; } public Guid? AssignmentId { get; set; }
public string CreaterEmail { get; set; }
public string AssignmentTitle { get; set; } = string.Empty; public string AssignmentTitle { get; set; } = string.Empty;
public string Description { get; set; } public string Description { get; set; }
public string SubjectArea { get; set; } public string SubjectArea { get; set; }
public List<QuestionGroupDto> QuestionGroups { get; set; } = new List<QuestionGroupDto>(); public QuestionGroupDto QuestionGroups { get; set; } = new QuestionGroupDto();
} }
public class QuestionGroupDto public class QuestionGroupDto
{ {
public int Index { get; set; } public byte Index { get; set; }
public string Title { get; set; } public string? Title { get; set; }
public int Score { get; set; } public float Score { get; set; }
public string QuestionReference { get; set; }
public string? Descript { get; set; }
public List<SubQuestionDto> SubQuestions { get; set; } = new List<SubQuestionDto>(); public List<SubQuestionDto> SubQuestions { get; set; } = new List<SubQuestionDto>();
public List<QuestionGroupDto> SubQuestionGroups { get; set; } = new List<QuestionGroupDto>(); public List<QuestionGroupDto> SubQuestionGroups { get; set; } = new List<QuestionGroupDto>();
public bool ValidQuestionGroup { get; set; } = false;
} }
public class SubQuestionDto public class SubQuestionDto
@@ -35,16 +37,17 @@ namespace Entities.DTO
public byte Index { get; set; } public byte Index { get; set; }
public string Stem { get; set; } public string? Stem { get; set; }
public float Score { get; set; } public float Score { get; set; }
public List<OptionDto> Options { get; set; } = new List<OptionDto>(); public List<OptionDto> Options { get; set; } = new List<OptionDto>();
public string SampleAnswer { get; set; } public string? SampleAnswer { get; set; }
public string QuestionType { get; set; } public string? QuestionType { get; set; }
public string DifficultyLevel { get; set; } public string? DifficultyLevel { get; set; }
public bool ValidQuestion { get; set; } = false;
} }

View File

@@ -6,244 +6,244 @@ using System.IO; // 用于 XML 反序列化
namespace TechHelper.Client.Exam namespace TechHelper.Client.Exam
{ {
[XmlRoot("EP")] //[XmlRoot("EP")]
public class StringsList //public class StringsList
{ //{
[XmlElement("Q")] // [XmlElement("Q")]
public List<string> Items { get; set; } // public List<string> Items { get; set; }
} //}
// XML 根元素 <EP> //// XML 根元素 <EP>
[XmlRoot("EP")] //[XmlRoot("EP")]
public class ExamPaper //public class ExamPaper
{ //{
// XML 特性:<QGs> 包含 <QG> 列表 // // XML 特性:<QGs> 包含 <QG> 列表
[XmlArray("QGs")] // [XmlArray("QGs")]
[XmlArrayItem("QG")] // [XmlArrayItem("QG")]
[JsonProperty("QuestionGroups")] // [JsonProperty("QuestionGroups")]
public List<QuestionGroup> QuestionGroups { get; set; } = new List<QuestionGroup>(); // public List<QuestionGroup> QuestionGroups { get; set; } = new List<QuestionGroup>();
} //}
[XmlRoot("QG")] //[XmlRoot("QG")]
public class QuestionGroup //public class QuestionGroup
{ //{
// JSON 特性 // // JSON 特性
[JsonProperty("题号")] // [JsonProperty("题号")]
// XML 特性:作为 <QG Id="X"> 属性 // // XML 特性:作为 <QG Id="X"> 属性
[XmlAttribute("Id")] // [XmlAttribute("Id")]
public byte Id { get; set; } // public byte Id { get; set; }
[JsonProperty("标题")] // [JsonProperty("标题")]
[XmlElement("T")] // T for Title // [XmlElement("T")] // T for Title
public string Title { get; set; } // public string Title { get; set; }
[JsonProperty("分值")] // [JsonProperty("分值")]
[XmlAttribute("S")] // S for Score // [XmlAttribute("S")] // S for Score
public int Score { get; set; } // public int Score { get; set; }
[JsonProperty("题目引用")] // [JsonProperty("题目引用")]
[XmlElement("QR")] // QR for QuestionReference作为 <QR> 元素 // [XmlElement("QR")] // QR for QuestionReference作为 <QR> 元素
public string QuestionReference { get; set; } = ""; // 初始化为空字符串 // public string QuestionReference { get; set; } = ""; // 初始化为空字符串
[JsonProperty("子题目")] // [JsonProperty("子题目")]
[XmlArray("SQs")] // SQs 包含 <SQ> 列表 // [XmlArray("SQs")] // SQs 包含 <SQ> 列表
[XmlArrayItem("SQ")] // [XmlArrayItem("SQ")]
public List<SubQuestion> SubQuestions { get; set; } = new List<SubQuestion>(); // public List<SubQuestion> SubQuestions { get; set; } = new List<SubQuestion>();
[JsonProperty("子题组")] // [JsonProperty("子题组")]
[XmlArray("SQGs")] // SQGs 包含 <QG> 列表 (嵌套题组) // [XmlArray("SQGs")] // SQGs 包含 <QG> 列表 (嵌套题组)
[XmlArrayItem("QG")] // [XmlArrayItem("QG")]
public List<QuestionGroup> SubQuestionGroups { get; set; } = new List<QuestionGroup>(); // public List<QuestionGroup> SubQuestionGroups { get; set; } = new List<QuestionGroup>();
} //}
// 子题目类 //// 子题目类
public class SubQuestion //public class SubQuestion
{ //{
[JsonProperty("子题号")] // [JsonProperty("子题号")]
[XmlAttribute("Id")] // Id for SubId // [XmlAttribute("Id")] // Id for SubId
public byte SubId { get; set; } // public byte SubId { get; set; }
[JsonProperty("题干")] // [JsonProperty("题干")]
[XmlElement("T")] // T for Text (Stem) // [XmlElement("T")] // T for Text (Stem)
public string Stem { get; set; } // public string Stem { get; set; }
[JsonProperty("分值")] // [JsonProperty("分值")]
[XmlAttribute("S")] // S for Score // [XmlAttribute("S")] // S for Score
public int Score { get; set; } // 分值通常为整数 // public int Score { get; set; } // 分值通常为整数
[JsonProperty("选项")] // [JsonProperty("选项")]
[XmlArray("Os")] // Os 包含 <O> 列表 // [XmlArray("Os")] // Os 包含 <O> 列表
[XmlArrayItem("O")] // [XmlArrayItem("O")]
public List<Option> Options { get; set; } = new List<Option>(); // public List<Option> Options { get; set; } = new List<Option>();
[JsonProperty("示例答案")] // [JsonProperty("示例答案")]
[XmlElement("SA")] // SA for SampleAnswer // [XmlElement("SA")] // SA for SampleAnswer
public string SampleAnswer { get; set; } = ""; // public string SampleAnswer { get; set; } = "";
} //}
// 选项类,用于适配 <O V="X"/> 结构 //// 选项类,用于适配 <O V="X"/> 结构
public class Option //public class Option
{ //{
// XML 特性:作为 <O V="X"> 属性 // // XML 特性:作为 <O V="X"> 属性
[XmlAttribute("V")] // V for Value // [XmlAttribute("V")] // V for Value
// JSON 特性:如果 JSON 中的选项是 {"Value": "A"} 这样的对象,则需要 JsonProperty("Value") // // JSON 特性:如果 JSON 中的选项是 {"Value": "A"} 这样的对象,则需要 JsonProperty("Value")
// 但如果 JSON 选项只是 ["A", "B"] 这样的字符串数组则此Option类不适合JSON Options // // 但如果 JSON 选项只是 ["A", "B"] 这样的字符串数组则此Option类不适合JSON Options
// 需要明确你的JSON Options的结构。我假设你JSON Options是 List<string> // // 需要明确你的JSON Options的结构。我假设你JSON Options是 List<string>
// 如果是 List<string>则Options属性在SubQuestion中直接是List<string>Option类则不需要 // // 如果是 List<string>则Options属性在SubQuestion中直接是List<string>Option类则不需要
// 但根据你的精简XML需求Option类是必要的。 // // 但根据你的精简XML需求Option类是必要的。
// 所以这里需要你自己根据实际JSON Options结构选择。 // // 所以这里需要你自己根据实际JSON Options结构选择。
// 为了兼容XML我会保留Option类但如果JSON是List<string>Options属性会很复杂 // // 为了兼容XML我会保留Option类但如果JSON是List<string>Options属性会很复杂
public string Value { get; set; } // public string Value { get; set; }
} //}
// 独立的服务类来处理序列化和反序列化 //// 独立的服务类来处理序列化和反序列化
public static class ExamParser //public static class ExamParser
{ //{
// JSON 反序列化方法 // // JSON 反序列化方法
public static List<T> ParseExamJson<T>(string jsonContent) // public static List<T> ParseExamJson<T>(string jsonContent)
{ // {
string cleanedJson = jsonContent.Trim(); // string cleanedJson = jsonContent.Trim();
// 移除可能存在的 Markdown 代码块标记 // // 移除可能存在的 Markdown 代码块标记
if (cleanedJson.StartsWith("```json") && cleanedJson.EndsWith("```")) // if (cleanedJson.StartsWith("```json") && cleanedJson.EndsWith("```"))
{ // {
cleanedJson = cleanedJson.Substring("```json".Length, cleanedJson.Length - "```json".Length - "```".Length).Trim(); // cleanedJson = cleanedJson.Substring("```json".Length, cleanedJson.Length - "```json".Length - "```".Length).Trim();
} // }
// 移除可能存在的单引号包围(如果 AI 偶尔会这样输出) // // 移除可能存在的单引号包围(如果 AI 偶尔会这样输出)
if (cleanedJson.StartsWith("'") && cleanedJson.EndsWith("'")) // if (cleanedJson.StartsWith("'") && cleanedJson.EndsWith("'"))
{ // {
cleanedJson = cleanedJson.Substring(1, cleanedJson.Length - 2).Trim(); // cleanedJson = cleanedJson.Substring(1, cleanedJson.Length - 2).Trim();
} // }
try // try
{ // {
// 注意:这里假设你的 JSON 根直接是一个 QuestionGroup 列表 // // 注意:这里假设你的 JSON 根直接是一个 QuestionGroup 列表
// 如果你的 JSON 根是 { "QuestionGroups": [...] },则需要先反序列化到 ExamPaper // // 如果你的 JSON 根是 { "QuestionGroups": [...] },则需要先反序列化到 ExamPaper
List<T> examQuestions = JsonConvert.DeserializeObject<List<T>>(cleanedJson); // List<T> examQuestions = JsonConvert.DeserializeObject<List<T>>(cleanedJson);
return examQuestions; // return examQuestions;
} // }
catch (JsonSerializationException ex) // catch (JsonSerializationException ex)
{ // {
Console.WriteLine($"JSON 反序列化错误: {ex.Message}"); // Console.WriteLine($"JSON 反序列化错误: {ex.Message}");
Console.WriteLine($"内部异常: {ex.InnerException?.Message}"); // Console.WriteLine($"内部异常: {ex.InnerException?.Message}");
return null; // return null;
} // }
catch (Exception ex) // catch (Exception ex)
{ // {
Console.WriteLine($"处理错误: {ex.Message}"); // Console.WriteLine($"处理错误: {ex.Message}");
return null; // return null;
} // }
} // }
#region TEST // #region TEST
[XmlRoot("User")] // [XmlRoot("User")]
public class User // public class User
{ // {
[XmlAttribute("id")] // [XmlAttribute("id")]
public string Id { get; set; } // public string Id { get; set; }
[XmlElement("PersonalInfo")] // [XmlElement("PersonalInfo")]
public PersonalInfo PersonalInfo { get; set; } // public PersonalInfo PersonalInfo { get; set; }
[XmlArray("Roles")] // 包装元素 <Roles> // [XmlArray("Roles")] // 包装元素 <Roles>
[XmlArrayItem("Role")] // 集合中的每个项是 <Role> // [XmlArrayItem("Role")] // 集合中的每个项是 <Role>
public List<Role> Roles { get; set; } = new List<Role>(); // public List<Role> Roles { get; set; } = new List<Role>();
// 构造函数,方便测试 // // 构造函数,方便测试
public User() { } // public User() { }
} // }
public class PersonalInfo // public class PersonalInfo
{ // {
[XmlElement("FullName")] // [XmlElement("FullName")]
public string FullName { get; set; } // public string FullName { get; set; }
[XmlElement("EmailAddress")] // [XmlElement("EmailAddress")]
public string EmailAddress { get; set; } // public string EmailAddress { get; set; }
// 构造函数,方便测试 // // 构造函数,方便测试
public PersonalInfo() { } // public PersonalInfo() { }
} // }
public class Role // public class Role
{ // {
[XmlAttribute("type")] // [XmlAttribute("type")]
public string Type { get; set; } // public string Type { get; set; }
// 构造函数,方便测试 // // 构造函数,方便测试
public Role() { } // public Role() { }
} // }
#endregion // #endregion
// XML 反序列化方法 // // XML 反序列化方法
public static T ParseExamXml<T>(string xmlContent) // public static T ParseExamXml<T>(string xmlContent)
{ // {
string cleanedXml = xmlContent.Trim(); // string cleanedXml = xmlContent.Trim();
if (cleanedXml.StartsWith("'") && cleanedXml.EndsWith("'")) // if (cleanedXml.StartsWith("'") && cleanedXml.EndsWith("'"))
{ // {
cleanedXml = cleanedXml.Substring(1, cleanedXml.Length - 2); // cleanedXml = cleanedXml.Substring(1, cleanedXml.Length - 2);
} // }
if (cleanedXml.StartsWith("```xml") && cleanedXml.EndsWith("```")) // if (cleanedXml.StartsWith("```xml") && cleanedXml.EndsWith("```"))
{ // {
cleanedXml = cleanedXml.Substring("```xml".Length, cleanedXml.Length - "```xml".Length - "```".Length).Trim(); // cleanedXml = cleanedXml.Substring("```xml".Length, cleanedXml.Length - "```xml".Length - "```".Length).Trim();
} // }
XmlSerializer serializer = new XmlSerializer(typeof(T)); // XmlSerializer serializer = new XmlSerializer(typeof(T));
using (StringReader reader = new StringReader(cleanedXml)) // using (StringReader reader = new StringReader(cleanedXml))
{ // {
try // try
{ // {
T user = (T)serializer.Deserialize(reader); // T user = (T)serializer.Deserialize(reader);
return user; // return user;
} // }
catch (InvalidOperationException ex) // catch (InvalidOperationException ex)
{ // {
Console.WriteLine($"XML 反序列化操作错误: {ex.Message}"); // Console.WriteLine($"XML 反序列化操作错误: {ex.Message}");
Console.WriteLine($"内部异常: {ex.InnerException?.Message}"); // Console.WriteLine($"内部异常: {ex.InnerException?.Message}");
return default(T); // return default(T);
} // }
catch (Exception ex) // catch (Exception ex)
{ // {
Console.WriteLine($"处理错误: {ex.Message}"); // Console.WriteLine($"处理错误: {ex.Message}");
return default(T); // return default(T);
} // }
} // }
} // }
public static List<QuestionGroup> ParseExamXmlFormQG(string xmlContent) // public static List<QuestionGroup> ParseExamXmlFormQG(string xmlContent)
{ // {
// 移除可能存在的 Markdown 代码块标记 // // 移除可能存在的 Markdown 代码块标记
if (xmlContent.StartsWith("```xml") && xmlContent.EndsWith("```")) // if (xmlContent.StartsWith("```xml") && xmlContent.EndsWith("```"))
{ // {
xmlContent = xmlContent.Substring("```xml".Length, xmlContent.Length - "```xml".Length - "```".Length).Trim(); // xmlContent = xmlContent.Substring("```xml".Length, xmlContent.Length - "```xml".Length - "```".Length).Trim();
} // }
var serializer = new XmlSerializer(typeof(List<QuestionGroup>), new XmlRootAttribute("QGs")); // var serializer = new XmlSerializer(typeof(List<QuestionGroup>), new XmlRootAttribute("QGs"));
using (StringReader reader = new StringReader(xmlContent)) // using (StringReader reader = new StringReader(xmlContent))
{ // {
try // try
{ // {
List<QuestionGroup> questionGroups = (List<QuestionGroup>)serializer.Deserialize(reader); // List<QuestionGroup> questionGroups = (List<QuestionGroup>)serializer.Deserialize(reader);
return questionGroups; // return questionGroups;
} // }
catch (InvalidOperationException ex) // catch (InvalidOperationException ex)
{ // {
Console.WriteLine($"XML 反序列化操作错误: {ex.Message}"); // Console.WriteLine($"XML 反序列化操作错误: {ex.Message}");
Console.WriteLine($"内部异常: {ex.InnerException?.Message}"); // Console.WriteLine($"内部异常: {ex.InnerException?.Message}");
return null; // return null;
} // }
catch (Exception ex) // catch (Exception ex)
{ // {
Console.WriteLine($"处理错误: {ex.Message}"); // Console.WriteLine($"处理错误: {ex.Message}");
return null; // return null;
} // }
} // }
} // }
} //}
} }

View File

@@ -1,627 +0,0 @@
using System.Text.RegularExpressions;
namespace TechHelper.Client.Exam.Parse
{
public class ExamPaper
{
public string Title { get; set; } = "未识别试卷标题";
public string Descript { get; set; } = "未识别试卷描述";
public string SubjectArea { get; set; } = "试卷类别";
public List<MajorQuestionGroup> MajorQuestionGroups { get; set; } = new List<MajorQuestionGroup>();
public List<Question> TopLevelQuestions { get; set; } = new List<Question>();
}
public class MajorQuestionGroup
{
public string Title { get; set; } = string.Empty;
public string Descript { get; set; } = string.Empty;
public float Score { get; set; }
public List<MajorQuestionGroup> SubMajorQuestionGroups { get; set; } = new List<MajorQuestionGroup>();
public List<Question> Questions { get; set; } = new List<Question>();
public int Priority { get; set; }
}
public class Question
{
public string Number { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
public float Score { get; set; }
public List<Option> Options { get; set; } = new List<Option>();
public List<Question> SubQuestions { get; set; } = new List<Question>();
public int Priority { get; set; }
}
public class Option
{
public string Label { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
}
/// <summary>
/// 表示一个带有优先级的正则表达式配置
/// </summary>
public class RegexPatternConfig
{
public string Pattern { get; set; } // 正则表达式字符串
public int Priority { get; set; } // 优先级,数字越小优先级越高
public Regex Regex { get; private set; } // 编译后的Regex对象用于性能优化
public RegexPatternConfig(string pattern, int priority)
{
Pattern = pattern;
Priority = priority;
Regex = new Regex(pattern, RegexOptions.Multiline | RegexOptions.Compiled); // 多行模式,编译以提高性能
}
}
/// <summary>
/// 试卷解析的配置类,包含所有正则表达式
/// </summary>
public class ExamParserConfig
{
public List<RegexPatternConfig> MajorQuestionGroupPatterns { get; set; } = new List<RegexPatternConfig>();
public List<RegexPatternConfig> QuestionPatterns { get; set; } = new List<RegexPatternConfig>();
public List<RegexPatternConfig> OptionPatterns { get; set; } = new List<RegexPatternConfig>();
public ExamParserConfig()
{
// --- 大题组模式 (MajorQuestionGroupPatterns) ---
// 匹配格式: "一、选择题 (20分)" 或 "二、阅读理解"
// Group 1: 大题组编号 (e.g., "一", "二")
// Group 2: 大题组标题 (e.g., "选择题", "阅读理解")
// Group 3: 整个分数部分 (e.g., "(20分)") - 可选
// Group 4: 纯数字分数 (e.g., "20") - 可选
MajorQuestionGroupPatterns.Add(new RegexPatternConfig(@"^([一二三四五六七八九十]+)[、\.]\s*(.+?)(?:\s*\(((\d+(?:\.\d+)?))\s*分\))?\s*$", 1));
// --- 题目模式 (QuestionPatterns) ---
// 针对不同格式的题目编号,捕获题号、题干和可选的分数
// Group 1: 题目编号 (e.g., "1", "(1)", "①")
// Group 2: 题干内容 (不含分数)
// Group 3: 整个分数部分 (e.g., "(5分)") - 可选
// Group 4: 纯数字分数 (e.g., "5") - 可选
// 模式 1: "1. 这是一个题目 (5分)" 或 "1. 这是一个题目"
QuestionPatterns.Add(new RegexPatternConfig(@"^(\d+)\.\s*(.+?)(?:\s*\(((\d+(?:\.\d+)?))\s*分\))?\s*$", 1));
// 模式 2: "(1) 这是一个子题目 (3分)" 或 "(1) 这是一个子题目"
QuestionPatterns.Add(new RegexPatternConfig(@"^\((\d+)\)\s*(.+?)(?:\s*\(((\d+(?:\.\d+)?))\s*分\))?\s*$", 2));
// 模式 3: "① 这是一个更深层次的子题目 (2分)" 或 "① 这是一个更深层次的子题目"
QuestionPatterns.Add(new RegexPatternConfig(@"^[①②③④⑤⑥⑦⑧⑨⑩]+\s*(.+?)(?:\s*\(((\d+(?:\.\d+)?))\s*分\))?\s*$", 3));
// --- 选项模式 (OptionPatterns) ---
// 匹配格式: "A. 选项内容"
// Group 1: 选项标签 (e.g., "A.")
// Group 2: 选项内容
OptionPatterns.Add(new RegexPatternConfig(@"^([A-Z]\.)\s*(.*)$", 1)); // 大写字母选项
OptionPatterns.Add(new RegexPatternConfig(@"^([a-z]\.)\s*(.*)$", 2)); // 小写字母选项
// --- 忽略模式 (IgnoredPatterns) ---
// 匹配空行或只包含空格的行,避免干扰解析流程
//IgnoredPatterns.Add(new RegexPatternConfig(@"^\s*$", 1));
//// 匹配试卷结尾的常见字符,防止被错误解析
//IgnoredPatterns.Add(new RegexPatternConfig(@"^\s*(试卷到此结束)\s*$", 1));
//IgnoredPatterns.Add(new RegexPatternConfig(@"^\s*(本卷共[0-9]+页)\s*$", 1));
// 标题和描述虽然你没要,但在实际解析中,这些模式有助于区分内容块,
// 否则它们可能会被其他模式(如题目模式)错误匹配。
// 建议你保留所有模式,但在本回复中,我只给出你要求的部分。
}
}
public class PotentialMatch
{
public int StartIndex { get; set; }
public int EndIndex { get; set; } // 匹配到的结构在原始文本中的结束位置
public string MatchedText { get; set; } // 匹配到的完整行或段落
public Match RegexMatch { get; set; } // 原始的Regex.Match对象方便获取捕获组
public RegexPatternConfig PatternConfig { get; set; } // 匹配到的模式配置
public MatchType Type { get; set; } // 枚举MajorQuestionGroup, Question, Option, etc.
}
public enum MatchType
{
MajorQuestionGroup,
Question,
Option,
Other // 如果有其他需要识别的类型
}
/// <summary>
/// 负责扫描原始文本,收集所有潜在的匹配项(题组、题目、选项)。
/// 它只进行匹配,不进行结构化归属。
/// </summary>
public class ExamDocumentScanner
{
private readonly ExamParserConfig _config;
public ExamDocumentScanner(ExamParserConfig config)
{
_config = config;
}
/// <summary>
/// 扫描给定的文本,返回所有潜在的匹配项,并按起始位置排序。
/// </summary>
/// <param name="text">要扫描的文本</param>
/// <returns>所有匹配到的 PotentialMatch 列表</returns>
public List<PotentialMatch> Scan(string text)
{
var allPotentialMatches = new List<PotentialMatch>();
// 扫描所有题组模式
foreach (var patternConfig in _config.MajorQuestionGroupPatterns)
{
foreach (Match match in patternConfig.Regex.Matches(text))
{
allPotentialMatches.Add(new PotentialMatch
{
StartIndex = match.Index,
EndIndex = match.Index + match.Length,
MatchedText = match.Value,
RegexMatch = match,
PatternConfig = patternConfig,
Type = MatchType.MajorQuestionGroup
});
}
}
// 扫描所有题目模式
foreach (var patternConfig in _config.QuestionPatterns)
{
foreach (Match match in patternConfig.Regex.Matches(text))
{
allPotentialMatches.Add(new PotentialMatch
{
StartIndex = match.Index,
EndIndex = match.Index + match.Length,
MatchedText = match.Value,
RegexMatch = match,
PatternConfig = patternConfig,
Type = MatchType.Question
});
}
}
// 扫描所有选项模式
foreach (var patternConfig in _config.OptionPatterns)
{
foreach (Match match in patternConfig.Regex.Matches(text))
{
allPotentialMatches.Add(new PotentialMatch
{
StartIndex = match.Index,
EndIndex = match.Index + match.Length,
MatchedText = match.Value,
RegexMatch = match,
PatternConfig = patternConfig,
Type = MatchType.Option
});
}
}
// 统一按起始位置排序
return allPotentialMatches.OrderBy(pm => pm.StartIndex).ToList();
}
}
public class ExamStructureBuilder
{
private readonly ExamParserConfig _config;
public ExamStructureBuilder(ExamParserConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config), "ExamParserConfig cannot be null.");
}
public ExamPaper BuildExamPaper(string fullExamText, List<PotentialMatch> allPotentialMatches)
{
if (string.IsNullOrWhiteSpace(fullExamText))
{
throw new ArgumentException("Full exam text cannot be null or empty.", nameof(fullExamText));
}
if (allPotentialMatches == null)
{
throw new ArgumentNullException(nameof(allPotentialMatches), "Potential matches list cannot be null.");
}
var examPaper = new ExamPaper();
try
{
examPaper.Title = GetExamTitle(fullExamText);
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to extract exam title.", ex);
}
var majorQGStack = new Stack<MajorQuestionGroup>();
MajorQuestionGroup currentMajorQG = null;
var questionStack = new Stack<Question>();
Question currentQuestion = null;
int currentContentStart = 0;
if (allPotentialMatches.Any() && allPotentialMatches[0].StartIndex > 0)
{
string introText = fullExamText.Substring(0, allPotentialMatches[0].StartIndex).Trim();
if (!string.IsNullOrWhiteSpace(introText))
{
examPaper.Descript = introText;
}
}
for (int i = 0; i < allPotentialMatches.Count; i++)
{
var pm = allPotentialMatches[i];
try
{
// Validate potential match data
if (pm.StartIndex < currentContentStart || pm.EndIndex > fullExamText.Length || pm.StartIndex > pm.EndIndex)
{
throw new ArgumentOutOfRangeException(
$"PotentialMatch at index {i} has invalid start/end indices. Start: {pm.StartIndex}, End: {pm.EndIndex}, CurrentContentStart: {currentContentStart}, FullTextLength: {fullExamText.Length}");
}
if (pm.RegexMatch == null || pm.PatternConfig == null)
{
throw new InvalidOperationException($"PotentialMatch at index {i} is missing RegexMatch or PatternConfig.");
}
string precedingText = fullExamText.Substring(currentContentStart, pm.StartIndex - currentContentStart).Trim();
if (!string.IsNullOrWhiteSpace(precedingText))
{
if (currentQuestion != null)
{
ProcessQuestionContent(currentQuestion, precedingText,
GetSubMatchesForRange(allPotentialMatches, currentContentStart, pm.StartIndex));
}
else if (currentMajorQG != null)
{
currentMajorQG.Descript += (string.IsNullOrWhiteSpace(currentMajorQG.Descript) ? "" : "\n") + precedingText;
}
else
{
// Append to ExamPaper.Description if it's top-level descriptive text
examPaper.Descript += (string.IsNullOrWhiteSpace(examPaper.Descript) ? "" : "\n") + precedingText;
}
}
if (pm.Type == MatchType.MajorQuestionGroup)
{
try
{
while (majorQGStack.Any() && pm.PatternConfig.Priority <= majorQGStack.Peek().Priority)
{
majorQGStack.Pop();
}
// Check if regex match groups exist before accessing
if (pm.RegexMatch.Groups.Count < 2)
{
throw new InvalidOperationException($"MajorQuestionGroup match at index {i} does not have enough regex groups for Title.");
}
float score = 0;
if (pm.RegexMatch.Groups.Count > 2 && pm.RegexMatch.Groups[2].Success)
{
if (!float.TryParse(pm.RegexMatch.Groups[2].Value, out score))
{
throw new FormatException($"Failed to parse score '{pm.RegexMatch.Groups[2].Value}' for MajorQuestionGroup at index {i}.");
}
}
MajorQuestionGroup newMajorQG = new MajorQuestionGroup
{
Title = pm.RegexMatch.Groups[1].Value.Trim(),
Score = score,
Priority = pm.PatternConfig.Priority
};
if (majorQGStack.Any())
{
majorQGStack.Peek().SubMajorQuestionGroups.Add(newMajorQG);
}
else
{
examPaper.MajorQuestionGroups.Add(newMajorQG);
}
majorQGStack.Push(newMajorQG);
currentMajorQG = newMajorQG;
questionStack.Clear();
currentQuestion = null;
}
catch (Exception ex)
{
throw new InvalidOperationException($"Error processing MajorQuestionGroup at index {i} (MatchedText: '{pm.MatchedText}').", ex);
}
}
else if (pm.Type == MatchType.Question)
{
try
{
while (questionStack.Any() && pm.PatternConfig.Priority <= questionStack.Peek().Priority)
{
questionStack.Pop();
}
if (pm.RegexMatch.Groups.Count < 3)
{
throw new InvalidOperationException($"Question match at index {i} does not have enough regex groups for Number and Text.");
}
Question newQuestion = new Question
{
Number = pm.RegexMatch.Groups[1].Value.Trim(),
Text = pm.RegexMatch.Groups[2].Value.Trim(),
Priority = pm.PatternConfig.Priority
};
if (pm.RegexMatch.Groups.Count > 3 && pm.RegexMatch.Groups[3].Success) // Assuming score is group 3 if available
{
float score = 0;
if (!float.TryParse(pm.RegexMatch.Groups[3].Value, out score))
{
throw new FormatException($"Failed to parse score '{pm.RegexMatch.Groups[3].Value}' for Question at index {i}.");
}
newQuestion.Score = score;
}
if (questionStack.Any())
{
questionStack.Peek().SubQuestions.Add(newQuestion);
}
else if (currentMajorQG != null)
{
currentMajorQG.Questions.Add(newQuestion);
}
else
{
examPaper.TopLevelQuestions.Add(newQuestion);
}
questionStack.Push(newQuestion);
currentQuestion = newQuestion;
}
catch (Exception ex)
{
throw new InvalidOperationException($"Error processing Question at index {i} (MatchedText: '{pm.MatchedText}').", ex);
}
}
else if (pm.Type == MatchType.Option)
{
try
{
if (currentQuestion != null)
{
if (pm.RegexMatch.Groups.Count < 3)
{
throw new InvalidOperationException($"Option match at index {i} does not have enough regex groups for Label and Text.");
}
Option newOption = new Option
{
Label = pm.RegexMatch.Groups[1].Value.Trim(),
Text = pm.RegexMatch.Groups[2].Value.Trim()
};
currentQuestion.Options.Add(newOption);
}
else
{
// This indicates a structural issue in the exam text
throw new InvalidOperationException($"Found isolated Option at index {i} (MatchedText: '{pm.MatchedText}'). Options must belong to a question.");
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"Error processing Option at index {i} (MatchedText: '{pm.MatchedText}').", ex);
}
}
currentContentStart = pm.EndIndex;
}
catch (Exception ex)
{
// Catch any unexpected errors during the main loop iteration
throw new InvalidOperationException($"An unexpected error occurred during processing of PotentialMatch at index {i}.", ex);
}
}
// --- Step 4: Process remaining content after the last match ---
if (currentContentStart < fullExamText.Length)
{
try
{
string remainingText = fullExamText.Substring(currentContentStart).Trim();
if (!string.IsNullOrWhiteSpace(remainingText))
{
if (currentQuestion != null)
{
ProcessQuestionContent(currentQuestion, remainingText,
GetSubMatchesForRange(allPotentialMatches, currentContentStart, fullExamText.Length));
}
else if (currentMajorQG != null)
{
currentMajorQG.Descript += (string.IsNullOrWhiteSpace(currentMajorQG.Descript) ? "" : "\n") + remainingText;
}
else
{
examPaper.Descript += (string.IsNullOrWhiteSpace(examPaper.Descript) ? "" : "\n") + remainingText;
}
}
}
catch (Exception ex)
{
throw new InvalidOperationException("Error processing remaining text after all potential matches.", ex);
}
}
return examPaper;
}
/// <summary>
/// Extracts the exam title (simple implementation)
/// </summary>
private string GetExamTitle(string examPaperText)
{
try
{
var firstLine = examPaperText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
return firstLine ?? "未识别试卷标题";
}
catch (Exception ex)
{
throw new InvalidOperationException("An error occurred while trying to extract the exam title from the text.", ex);
}
}
/// <summary>
/// Gets a subset of the given PotentialMatch list within a specified range.
/// This method helps ProcessQuestionContent by providing Options and SubQuestions within that range.
/// </summary>
private List<PotentialMatch> GetSubMatchesForRange(List<PotentialMatch> allMatches, int start, int end)
{
try
{
// Input validation for range
if (start < 0 || end < start)
{
throw new ArgumentOutOfRangeException($"Invalid range provided to GetSubMatchesForRange. Start: {start}, End: {end}");
}
// Ensure allMatches is not null before querying
if (allMatches == null)
{
return new List<PotentialMatch>();
}
return allMatches.Where(pm => pm.StartIndex >= start && pm.StartIndex < end).ToList();
}
catch (Exception ex)
{
throw new InvalidOperationException($"Error getting sub-matches for range [{start}, {end}).", ex);
}
}
/// <summary>
/// Processes the content of a Question, mainly for parsing Options and identifying unstructured text.
/// </summary>
private void ProcessQuestionContent(Question question, string contentText, List<PotentialMatch> potentialMatchesInScope)
{
if (question == null)
{
throw new ArgumentNullException(nameof(question), "Question cannot be null in ProcessQuestionContent.");
}
if (contentText == null) // contentText can be empty, but not null
{
throw new ArgumentNullException(nameof(contentText), "Content text cannot be null in ProcessQuestionContent.");
}
if (potentialMatchesInScope == null)
{
throw new ArgumentNullException(nameof(potentialMatchesInScope), "Potential matches in scope cannot be null.");
}
try
{
int lastOptionEndIndex = 0;
foreach (var pm in potentialMatchesInScope.OrderBy(p => p.StartIndex))
{
try
{
if (pm.Type == MatchType.Option)
{
// Check for valid indices
if (pm.StartIndex < lastOptionEndIndex || pm.StartIndex > contentText.Length || pm.EndIndex > contentText.Length)
{
throw new ArgumentOutOfRangeException(
$"Option match at index {pm.StartIndex} has invalid indices within content text. MatchedText: '{pm.MatchedText}'");
}
if (pm.StartIndex > lastOptionEndIndex)
{
string textBeforeOption = contentText.Substring(lastOptionEndIndex, pm.StartIndex - lastOptionEndIndex).Trim();
if (!string.IsNullOrWhiteSpace(textBeforeOption))
{
question.Text += (string.IsNullOrWhiteSpace(question.Text) ? "" : "\n") + textBeforeOption;
}
}
if (pm.RegexMatch.Groups.Count < 3)
{
throw new InvalidOperationException($"Option regex match '{pm.MatchedText}' does not have enough groups for label and text.");
}
var newOption = new Option
{
Label = pm.RegexMatch.Groups[1].Value.Trim(),
Text = pm.RegexMatch.Groups[2].Value.Trim()
};
question.Options.Add(newOption);
lastOptionEndIndex = pm.EndIndex;
}
// TODO: If there are SubQuestion types, they can be processed similarly here.
}
catch (Exception innerEx)
{
throw new InvalidOperationException($"Error processing a potential match ({pm.Type}) within question content (MatchedText: '{pm.MatchedText}').", innerEx);
}
}
// Process any remaining text after all options
if (lastOptionEndIndex < contentText.Length)
{
string remainingContent = contentText.Substring(lastOptionEndIndex).Trim();
if (!string.IsNullOrWhiteSpace(remainingContent))
{
question.Text += (string.IsNullOrWhiteSpace(question.Text) ? "" : "\n") + remainingContent;
}
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"An error occurred while processing content for Question '{question.Number}'.", ex);
}
}
}
public class ExamParser
{
private readonly ExamParserConfig _config;
private readonly ExamDocumentScanner _scanner;
private readonly ExamStructureBuilder _builder;
public ExamParser(ExamParserConfig config)
{
_config = config;
_scanner = new ExamDocumentScanner(_config);
_builder = new ExamStructureBuilder(_config);
}
/// <summary>
/// 解析给定的试卷文本,返回结构化的 ExamPaper 对象。
/// </summary>
/// <param name="examPaperText">完整的试卷文本</param>
/// <returns>解析后的 ExamPaper 对象</returns>
public ExamPaper ParseExamPaper(string examPaperText)
{
// 1. 扫描:一次性扫描整个文本,收集所有潜在的匹配项
List<PotentialMatch> allPotentialMatches = _scanner.Scan(examPaperText);
// 2. 构建:根据扫描结果和原始文本,线性遍历并构建层级结构
ExamPaper parsedExam = _builder.BuildExamPaper(examPaperText, allPotentialMatches);
return parsedExam;
}
}
}

View File

@@ -1,43 +0,0 @@
@using TechHelper.Client.Exam.Parse
<MudCard Class="my-2 pa-2" Outlined="true" Elevation="1">
<MudCardContent>
<MudText Typo="Typo.subtitle1">
<b>@Question.Number</b> @((MarkupString)Question.Text)
@if (Question.Score > 0)
{
<MudText Typo="Typo.body2" Class="d-inline ml-2">(@Question.Score 分)</MudText>
}
</MudText>
@* 显示选项 - 不使用 MudList *@
@if (Question.Options.Any())
{
<div class="mt-2">
@* 使用普通的 div 容器,你可以添加自定义 CSS 类进行样式控制 *@
@foreach (var option in Question.Options)
{
<MudText Typo="Typo.body2" Class="my-1">
@* 为每个选项文本添加一些边距 *@
<b>@option.Label</b> @((MarkupString)option.Text)
</MudText>
}
</div>
}
@* 递归显示子题目 *@
@if (Question.SubQuestions.Any())
{
<MudText Typo="Typo.subtitle2" Class="my-2">子题目:</MudText>
@foreach (var subQuestion in Question.SubQuestions)
{
<QuestionCard Question="subQuestion" />
}
}
</MudCardContent>
</MudCard>
@code {
[Parameter]
public Question Question { get; set; }
}

View File

@@ -1,43 +0,0 @@
@using TechHelper.Client.Exam.Parse
@* SubMajorQuestionGroupDisplay.razor *@
<MudExpansionPanels>
@foreach (var majorQG in MajorQGList)
{
<MudExpansionPanel Text="@majorQG.Title" DisableRipple="true">
<MudCard Class="mt-2" Outlined="true">
<MudCardContent>
@if (!string.IsNullOrWhiteSpace(majorQG.Descript))
{
<MudText Typo="Typo.body2"><b>描述:</b> @((MarkupString)majorQG.Descript)</MudText>
}
@if (majorQG.Score > 0)
{
<MudText Typo="Typo.body2"><b>总分:</b> @majorQG.Score 分</MudText>
}
@* 显示当前子题组下的题目 *@
@if (majorQG.Questions.Any())
{
<MudText Typo="Typo.subtitle1" Class="my-2">题目:</MudText>
@foreach (var question in majorQG.Questions)
{
<QuestionCard Question="question" />
}
}
@* 递归显示更深层次的子题组 *@
@if (majorQG.SubMajorQuestionGroups.Any())
{
<MudText Typo="Typo.subtitle1" Class="my-2">子题组:</MudText>
<SubMajorQuestionGroupDisplay MajorQGList="majorQG.SubMajorQuestionGroups" />
}
</MudCardContent>
</MudCard>
</MudExpansionPanel>
}
</MudExpansionPanels>
@code {
[Parameter]
public List<MajorQuestionGroup> MajorQGList { get; set; }
}

View File

@@ -0,0 +1,220 @@
using Entities.DTO;
using System.Text.Json.Serialization;
using System.Text.Json;
namespace TechHelper.Client.Exam
{
public static class ExamPaperExtensions
{
public static ExamDto ConvertToExamDTO(this ExamPaper examPaper)
{
ExamDto dto = new ExamDto();
dto.AssignmentTitle = examPaper.AssignmentTitle;
dto.Description = examPaper.Description;
dto.SubjectArea = examPaper.SubjectArea;
dto.QuestionGroups.Title = examPaper.AssignmentTitle;
dto.QuestionGroups.Descript = examPaper.Description;
// 处理顶级 QuestionGroups
foreach (var qg in examPaper.QuestionGroups)
{
var qgd = new QuestionGroupDto();
// 顶级 QuestionGroup其父组当然无效 (false),所以 isParentGroupValidChain 为 false
ParseMajorQuestionGroup(qg, qgd, false);
dto.QuestionGroups.SubQuestionGroups.Add(qgd);
}
// 处理 TopLevelQuestions
foreach (var question in examPaper.TopLevelQuestions)
{
// 对于 TopLevelQuestions它们没有父组所以 isParentGroupValidChain 初始为 false
// 如果顶级 Question 包含子问题,则将其视为一个 QuestionGroupDto
if (question.SubQuestions != null && question.SubQuestions.Any())
{
var qgDto = new QuestionGroupDto
{
Title = question.Stem,
Score = (int)question.Score,
Descript = "", // 顶级 Question 默认无描述
};
// 判断当前组是否有效:如果有描述,则为有效组
qgDto.ValidQuestionGroup = !string.IsNullOrEmpty(qgDto.Descript);
// 传递给子项的 isParentGroupValidChain 状态:如果当前组有效,则传递 true否则继承父级状态 (此处为 false)
ParseQuestionWithSubQuestions(question, qgDto, qgDto.ValidQuestionGroup);
dto.QuestionGroups.SubQuestionGroups.Add(qgDto);
}
else // 如果顶级 Question 没有子问题,则它本身就是一个独立的 SubQuestionDto放在一个容器 QuestionGroupDto 中
{
var qgDto = new QuestionGroupDto
{
Title = question.Stem,
Score = (int)question.Score,
Descript = "", // 独立题目的容器组通常无描述
};
// 独立题目的容器组,如果没有描述,则不是“有效组”
qgDto.ValidQuestionGroup = !string.IsNullOrEmpty(qgDto.Descript);
var subQuestionDto = new SubQuestionDto();
// 此时qgDto.ValidQuestionGroup 为 false所以传入 true表示题目是有效的
// 因为其父组链 (此处为自身) 不是有效组
ParseSingleQuestion(question, subQuestionDto, !qgDto.ValidQuestionGroup);
qgDto.SubQuestions.Add(subQuestionDto);
dto.QuestionGroups.SubQuestionGroups.Add(qgDto);
}
}
return dto;
}
// 解析 MajorQuestionGroup 及其子项
// isParentGroupValidChain 参数表示从顶层到当前组的任一父组是否已经是“有效组”
private static void ParseMajorQuestionGroup(MajorQuestionGroup qg, QuestionGroupDto qgd, bool isParentGroupValidChain)
{
qgd.Title = qg.Title;
qgd.Score = (int)qg.Score;
qgd.Descript = qg.Descript;
// 判断当前组是否有效:如果有描述,并且其父级链中没有任何一个组是有效组,则当前组有效
qgd.ValidQuestionGroup = !string.IsNullOrEmpty(qg.Descript) && !isParentGroupValidChain;
// 更新传递给子项的 isParentGroupValidChain 状态:
// 如果当前组是有效组 (即 qgd.ValidQuestionGroup 为 true),那么子项的父级链就包含了有效组
// 否则,子项的父级链有效性继承自其父级 (isParentGroupValidChain)
bool nextIsParentGroupValidChain = qgd.ValidQuestionGroup || isParentGroupValidChain;
// 处理子 QuestionGroup
if (qg.SubQuestionGroups != null)
{
qg.SubQuestionGroups.ForEach(sqg =>
{
var sqgd = new QuestionGroupDto();
ParseMajorQuestionGroup(sqg, sqgd, nextIsParentGroupValidChain);
qgd.SubQuestionGroups.Add(sqgd);
});
}
// 处理 MajorQuestionGroup 下的 SubQuestions
if (qg.SubQuestions != null)
{
qg.SubQuestions.ForEach(sq =>
{
// 如果 MajorQuestionGroup 下的 Question 包含子问题,则转为 QuestionGroupDto
if (sq.SubQuestions != null && sq.SubQuestions.Any())
{
var subQgd = new QuestionGroupDto
{
Title = sq.Stem,
Score = (int)sq.Score,
Descript = "" // 默认为空
};
// 判断当前组是否有效:如果有描述,并且其父级链中没有任何一个组是有效组,则当前组有效
subQgd.ValidQuestionGroup = !string.IsNullOrEmpty(subQgd.Descript) && !nextIsParentGroupValidChain;
ParseQuestionWithSubQuestions(sq, subQgd, subQgd.ValidQuestionGroup || nextIsParentGroupValidChain);
qgd.SubQuestionGroups.Add(subQgd);
}
else // 如果 MajorQuestionGroup 下的 Question 没有子问题,则转为 SubQuestionDto
{
var subQd = new SubQuestionDto();
// 只有当所有父组(包括当前组)都不是有效组时,这个题目才有效
ParseSingleQuestion(sq, subQd, !nextIsParentGroupValidChain);
qgd.SubQuestions.Add(subQd);
}
});
}
}
// 解析包含子问题的 Question将其转换为 QuestionGroupDto
// isParentGroupValidChain 参数表示从顶层到当前组的任一父组是否已经是“有效组”
private static void ParseQuestionWithSubQuestions(Question question, QuestionGroupDto qgd, bool isParentGroupValidChain)
{
qgd.Title = question.Stem;
qgd.Score = (int)question.Score;
qgd.Descript = ""; // 默认为空
// 判断当前组是否有效:如果有描述,并且其父级链中没有任何一个组是有效组,则当前组有效
qgd.ValidQuestionGroup = !string.IsNullOrEmpty(qgd.Descript) && !isParentGroupValidChain;
// 更新传递给子项的 isParentGroupValidChain 状态
bool nextIsParentGroupValidChain = qgd.ValidQuestionGroup || isParentGroupValidChain;
if (question.SubQuestions != null)
{
question.SubQuestions.ForEach(subQ =>
{
// 如果子问题本身还有子问题(多层嵌套),则继续创建 QuestionGroupDto
if (subQ.SubQuestions != null && subQ.SubQuestions.Any())
{
var nestedQgd = new QuestionGroupDto
{
Title = subQ.Stem,
Score = (int)subQ.Score,
Descript = "" // 默认为空
};
// 判断当前组是否有效:如果有描述,并且其父级链中没有任何一个组是有效组,则当前组有效
nestedQgd.ValidQuestionGroup = !string.IsNullOrEmpty(nestedQgd.Descript) && !nextIsParentGroupValidChain;
ParseQuestionWithSubQuestions(subQ, nestedQgd, nestedQgd.ValidQuestionGroup || nextIsParentGroupValidChain);
qgd.SubQuestionGroups.Add(nestedQgd);
}
else // 如果子问题没有子问题,则直接创建 SubQuestionDto
{
var subQd = new SubQuestionDto();
// 只有当所有父组(包括当前组)都不是有效组时,这个题目才有效
ParseSingleQuestion(subQ, subQd, !nextIsParentGroupValidChain);
qgd.SubQuestions.Add(subQd);
}
});
}
}
// 解析单个 Question (没有子问题) 为 SubQuestionDto
private static void ParseSingleQuestion(Question question, SubQuestionDto subQd, bool validQ)
{
subQd.Stem = question.Stem;
subQd.Score = (int)question.Score;
subQd.ValidQuestion = validQ; // 根据传入的 validQ 确定是否是“有效题目”
subQd.SampleAnswer = question.SampleAnswer;
subQd.QuestionType = question.QuestionType;
// 注意DifficultyLevel 在本地 Question 中没有,如果服务器需要,可能需要补充默认值或从其他地方获取
// subQd.DifficultyLevel = ...;
if (question.Options != null)
{
question.Options.ForEach(o =>
{
subQd.Options.Add(new OptionDto { Value = o.Label + o.Text });
});
}
}
public static string SerializeExamDto(this ExamDto dto)
{
// 配置序列化选项(可选)
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
return JsonSerializer.Serialize(dto, options);
}
public static ExamDto DeserializeExamDto(string jsonString)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return JsonSerializer.Deserialize<ExamDto>(jsonString, options);
}
}
}

View File

@@ -0,0 +1,740 @@
using Entities.DTO;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace TechHelper.Client.Exam
{
// --- 新增错误处理相关类 ---
public class ParseError
{
public ParseErrorType Type { get; }
public string Message { get; }
public int? Index { get; } // 错误发生的文本索引或匹配项索引
public string MatchedText { get; } // 如果与某个匹配项相关,记录其文本
public Exception InnerException { get; } // 捕获到的原始异常
public ParseError(ParseErrorType type, string message, int? index = null, string matchedText = null, Exception innerException = null)
{
Type = type;
Message = message;
Index = index;
MatchedText = matchedText;
InnerException = innerException;
}
public override string ToString()
{
var sb = new System.Text.StringBuilder();
sb.Append($"[{Type}] {Message}");
if (Index.HasValue) sb.Append($" (Index: {Index.Value})");
if (!string.IsNullOrEmpty(MatchedText)) sb.Append($" (MatchedText: '{MatchedText}')");
if (InnerException != null) sb.Append($" InnerException: {InnerException.Message}");
return sb.ToString();
}
}
public enum ParseErrorType
{
Validation = 1, // 输入验证失败
DataParsing = 2, // 数据解析失败(如数字转换)
Structural = 3, // 结构性问题(如选项没有对应的问题)
RegexMatchIssue = 4, // 正则表达式匹配结果不符合预期
UnexpectedError = 5 // 未预料到的通用错误
}
public class ExamPaper
{
public string AssignmentTitle { get; set; } = "未识别试卷标题";
public string Description { get; set; } = "未识别试卷描述";
public string SubjectArea { get; set; } = "试卷类别";
public List<MajorQuestionGroup> QuestionGroups { get; set; } = new List<MajorQuestionGroup>();
public List<Question> TopLevelQuestions { get; set; } = new List<Question>();
public List<ParseError> Errors { get; set; } = new List<ParseError>();
}
public class MajorQuestionGroup
{
public string Title { get; set; } = string.Empty;
public string Descript { get; set; } = string.Empty;
public float Score { get; set; }
public List<MajorQuestionGroup> SubQuestionGroups { get; set; } = new List<MajorQuestionGroup>();
public List<Question> SubQuestions { get; set; } = new List<Question>();
public int Priority { get; set; }
}
public class Question
{
public string Number { get; set; } = string.Empty;
public string Stem { get; set; } = string.Empty;
public float Score { get; set; }
public List<Option> Options { get; set; } = new List<Option>();
public List<Question> SubQuestions { get; set; } = new List<Question>();
public string SampleAnswer { get; set; } = string.Empty;
public string QuestionType { get; set; } = string.Empty;
public int Priority { get; set; }
}
public class Option
{
public string Label { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
}
/// <summary>
/// 表示一个带有优先级的正则表达式配置
/// </summary>
public class RegexPatternConfig
{
public string Pattern { get; set; } // 正则表达式字符串
public int Priority { get; set; } // 优先级,数字越小优先级越高
public Regex Regex { get; private set; } // 编译后的Regex对象用于性能优化
public RegexPatternConfig(string pattern, int priority)
{
Pattern = pattern;
Priority = priority;
Regex = new Regex(pattern, RegexOptions.Multiline | RegexOptions.Compiled); // 多行模式,编译以提高性能
}
}
public enum ExamParserEnum
{
MajorQuestionGroupPatterns = 0,
QuestionPatterns,
OptionPatterns
}
/// <summary>
/// 试卷解析的配置类,包含所有正则表达式
/// </summary>
public class ExamParserConfig
{
public List<RegexPatternConfig> MajorQuestionGroupPatterns { get; set; } = new List<RegexPatternConfig>();
public List<RegexPatternConfig> QuestionPatterns { get; set; } = new List<RegexPatternConfig>();
public List<RegexPatternConfig> OptionPatterns { get; set; } = new List<RegexPatternConfig>();
public ExamParserConfig()
{
MajorQuestionGroupPatterns.Add(new RegexPatternConfig(@"^([一二三四五六七八九十]+)[、\.]\s*(.+?)(?:\s*\(((\d+(?:\.\d+)?))\s*分\))?\s*$", 1));
MajorQuestionGroupPatterns.Add(new RegexPatternConfig(@"^\(([一二三四五六七八九十]{1,2}|十[一二三四五六七八九])\)\s*(.+?)(?:\s*\(((\d+(?:\.\d+)?))\s*分\))?\s*$", 2));
// 模式 1: "1. 这是一个题目 (5分)" 或 "1. 这是一个题目"
QuestionPatterns.Add(new RegexPatternConfig(@"^(\d+)\.\s*(.+?)(?:\s*\(((\d+(?:\.\d+)?))\s*分\))?\s*$", 1));
// 模式 2: "(1) 这是一个子题目 (3分)" 或 "(1) 这是一个子题目"
QuestionPatterns.Add(new RegexPatternConfig(@"^\((\d+)\)\s*(.+?)(?:\s*\(((\d+(?:\.\d+)?))\s*分\))?\s*$", 2));
// 模式 3: "① 这是一个更深层次的子题目 (2分)" 或 "① 这是一个更深层次的子题目"
QuestionPatterns.Add(new RegexPatternConfig(@"^[①②③④⑤⑥⑦⑧⑨⑩]+\s*(.+?)(?:\s*\(((\d+(?:\.\d+)?))\s*分\))?\s*$", 3));
OptionPatterns.Add(new RegexPatternConfig(@"([A-Z]\.)\s*(.*?)(?=[A-Z]\.|$)", 1)); // 大写字母选项
OptionPatterns.Add(new RegexPatternConfig(@"([a-z]\.)\s*(.*?)(?=[a-z]\.|$)", 1)); // 小写字母选项
}
}
public class PotentialMatch
{
public int StartIndex { get; set; }
public int EndIndex { get; set; } // 匹配到的结构在原始文本中的结束位置
public string MatchedText { get; set; } // 匹配到的完整行或段落
public Match RegexMatch { get; set; } // 原始的Regex.Match对象方便获取捕获组
public RegexPatternConfig PatternConfig { get; set; } // 匹配到的模式配置
public MatchType Type { get; set; } // 枚举MajorQuestionGroup, Question, Option, etc.
}
public enum MatchType
{
MajorQuestionGroup,
Question,
Option,
Other // 如果有其他需要识别的类型
}
/// <summary>
/// 负责扫描原始文本,收集所有潜在的匹配项(题组、题目、选项)。
/// 它只进行匹配,不进行结构化归属。
/// </summary>
public class ExamDocumentScanner
{
private readonly ExamParserConfig _config;
public ExamDocumentScanner(ExamParserConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config)); // 确保配置不为空
}
/// <summary>
/// 扫描给定的文本,返回所有潜在的匹配项,并按起始位置排序。
/// </summary>
/// <param name="text">要扫描的文本</param>
/// <returns>所有匹配到的 PotentialMatch 列表</returns>
public List<PotentialMatch> Scan(string text)
{
if (string.IsNullOrEmpty(text))
{
return new List<PotentialMatch>(); // 对于空文本,直接返回空列表
}
var allPotentialMatches = new List<PotentialMatch>();
// 扫描所有题组模式
foreach (var patternConfig in _config.MajorQuestionGroupPatterns)
{
foreach (Match match in patternConfig.Regex.Matches(text))
{
allPotentialMatches.Add(new PotentialMatch
{
StartIndex = match.Index,
EndIndex = match.Index + match.Length,
MatchedText = match.Value,
RegexMatch = match,
PatternConfig = patternConfig,
Type = MatchType.MajorQuestionGroup
});
}
}
// 扫描所有题目模式
foreach (var patternConfig in _config.QuestionPatterns)
{
foreach (Match match in patternConfig.Regex.Matches(text))
{
allPotentialMatches.Add(new PotentialMatch
{
StartIndex = match.Index,
EndIndex = match.Index + match.Length,
MatchedText = match.Value,
RegexMatch = match,
PatternConfig = patternConfig,
Type = MatchType.Question
});
}
}
// 扫描所有选项模式
foreach (var patternConfig in _config.OptionPatterns)
{
foreach (Match match in patternConfig.Regex.Matches(text))
{
allPotentialMatches.Add(new PotentialMatch
{
StartIndex = match.Index,
EndIndex = match.Index + match.Length,
MatchedText = match.Value,
RegexMatch = match,
PatternConfig = patternConfig,
Type = MatchType.Option
});
}
}
// 统一按起始位置排序
return allPotentialMatches.OrderBy(pm => pm.StartIndex).ToList();
}
}
public class ExamStructureBuilder
{
private readonly ExamParserConfig _config;
public ExamStructureBuilder(ExamParserConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config), "ExamParserConfig cannot be null.");
}
/// <summary>
/// Builds the ExamPaper structure from raw text and potential matches.
/// Collects and returns parsing errors encountered during the process.
/// </summary>
/// <param name="fullExamText">The complete text of the exam paper.</param>
/// <param name="allPotentialMatches">A list of all identified potential matches.</param>
/// <returns>An ExamPaper object containing the parsed structure and a list of errors.</returns>
/// <exception cref="ArgumentException">Thrown if fullExamText is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown if allPotentialMatches is null.</exception>
public ExamPaper BuildExamPaper(string fullExamText, List<PotentialMatch> allPotentialMatches)
{
// 核心输入验证仍然是必要的,因为这些错误是无法恢复的
if (string.IsNullOrWhiteSpace(fullExamText))
{
throw new ArgumentException("Full exam text cannot be null or empty.", nameof(fullExamText));
}
if (allPotentialMatches == null)
{
throw new ArgumentNullException(nameof(allPotentialMatches), "Potential matches list cannot be null.");
}
var examPaper = new ExamPaper(); // ExamPaper 现在包含一个 Errors 列表
// 尝试获取试卷标题
try
{
examPaper.AssignmentTitle = GetExamTitle(fullExamText);
}
catch (Exception ex)
{
// 如果获取标题失败,记录错误而不是抛出致命异常
examPaper.Errors.Add(new ParseError(ParseErrorType.UnexpectedError, "Failed to extract exam title.", innerException: ex));
examPaper.AssignmentTitle = "未识别试卷标题"; // 提供默认值
}
var majorQGStack = new Stack<MajorQuestionGroup>();
MajorQuestionGroup currentMajorQG = null;
var questionStack = new Stack<Question>();
Question currentQuestion = null;
int currentContentStart = 0;
// 处理试卷开头的描述性文本
if (allPotentialMatches.Any() && allPotentialMatches[0].StartIndex > 0)
{
string introText = fullExamText.Substring(0, allPotentialMatches[0].StartIndex).Trim();
if (!string.IsNullOrWhiteSpace(introText))
{
examPaper.Description += (string.IsNullOrWhiteSpace(examPaper.Description) ? "" : "\n") + introText;
}
}
currentContentStart = allPotentialMatches[0].StartIndex;
for (int i = 0; i < allPotentialMatches.Count; i++)
{
var pm = allPotentialMatches[i];
try
{
// **数据验证:不再抛出,而是记录错误**
if (pm.StartIndex < currentContentStart || pm.EndIndex > fullExamText.Length || pm.StartIndex > pm.EndIndex)
{
examPaper.Errors.Add(new ParseError(ParseErrorType.Validation,
$"PotentialMatch at index {i} has invalid start/end indices. Start: {pm.StartIndex}, End: {pm.EndIndex}, CurrentContentStart: {currentContentStart}, FullTextLength: {fullExamText.Length}",
index: i, matchedText: pm.MatchedText));
currentContentStart = Math.Max(currentContentStart, pm.EndIndex); // 尝试跳过这个损坏的匹配项
continue; // 跳过当前循环迭代,处理下一个匹配项
}
if (pm.RegexMatch == null || pm.PatternConfig == null)
{
examPaper.Errors.Add(new ParseError(ParseErrorType.Validation,
$"PotentialMatch at index {i} is missing RegexMatch or PatternConfig.",
index: i, matchedText: pm.MatchedText));
currentContentStart = Math.Max(currentContentStart, pm.EndIndex); // 尝试跳过这个损坏的匹配项
continue; // 跳过当前循环迭代,处理下一个匹配项
}
string precedingText = fullExamText.Substring(currentContentStart, pm.StartIndex - currentContentStart).Trim();
if (!string.IsNullOrWhiteSpace(precedingText))
{
if (currentQuestion != null)
{
// 将 examPaper.Errors 传递给 ProcessQuestionContent 收集错误
ProcessQuestionContent(currentQuestion, precedingText,
GetSubMatchesForRange(allPotentialMatches, currentContentStart, pm.StartIndex, examPaper.Errors),
examPaper.Errors);
}
else if (currentMajorQG != null)
{
currentMajorQG.Descript += (string.IsNullOrWhiteSpace(currentMajorQG.Descript) ? "" : "\n") + precedingText;
}
else
{
examPaper.Description += (string.IsNullOrWhiteSpace(examPaper.Description) ? "" : "\n") + precedingText;
}
}
if (pm.Type == MatchType.MajorQuestionGroup)
{
// 对 MajorQuestionGroup 的处理
try
{
while (majorQGStack.Any() && pm.PatternConfig.Priority <= majorQGStack.Peek().Priority)
{
majorQGStack.Pop();
}
// RegexMatch Groups 验证:不再抛出,记录错误
if (pm.RegexMatch.Groups.Count < 2 || string.IsNullOrWhiteSpace(pm.RegexMatch.Groups[1].Value))
{
examPaper.Errors.Add(new ParseError(ParseErrorType.RegexMatchIssue,
$"MajorQuestionGroup match at index {i} does not have enough regex groups or a valid title group (Group 1). Skipping this group.",
index: i, matchedText: pm.MatchedText));
currentContentStart = pm.EndIndex; // 继续,尝试跳过此项
continue;
}
float score = 0;
// 使用 float.TryParse 避免异常
if (pm.RegexMatch.Groups.Count > 3 && pm.RegexMatch.Groups[4].Success) // 假设纯数字分数是 Group 4
{
if (!float.TryParse(pm.RegexMatch.Groups[4].Value, out score))
{
examPaper.Errors.Add(new ParseError(ParseErrorType.DataParsing,
$"Failed to parse score '{pm.RegexMatch.Groups[4].Value}' for MajorQuestionGroup at index {i}. Defaulting to 0.",
index: i, matchedText: pm.MatchedText));
}
}
MajorQuestionGroup newMajorQG = new MajorQuestionGroup
{
Title = pm.RegexMatch.Groups[2].Value.Trim(), // 标题是 Group 2
Score = score,
Priority = pm.PatternConfig.Priority,
};
if (majorQGStack.Any())
{
majorQGStack.Peek().SubQuestionGroups.Add(newMajorQG);
}
else
{
examPaper.QuestionGroups.Add(newMajorQG);
}
currentContentStart = pm.EndIndex;
majorQGStack.Push(newMajorQG);
currentMajorQG = newMajorQG;
questionStack.Clear();
currentQuestion = null;
}
catch (Exception ex)
{
examPaper.Errors.Add(new ParseError(ParseErrorType.UnexpectedError,
$"An unexpected error occurred during processing MajorQuestionGroup at index {i}.",
index: i, matchedText: pm.MatchedText, innerException: ex));
currentContentStart = pm.EndIndex; // 尝试跳过此项
continue;
}
}
else if (pm.Type == MatchType.Question)
{
// 对 Question 的处理
try
{
while (questionStack.Any() && pm.PatternConfig.Priority <= questionStack.Peek().Priority)
{
questionStack.Pop();
}
// RegexMatch Groups 验证
if (pm.RegexMatch.Groups.Count < 3 || string.IsNullOrWhiteSpace(pm.RegexMatch.Groups[1].Value) || string.IsNullOrWhiteSpace(pm.RegexMatch.Groups[2].Value))
{
examPaper.Errors.Add(new ParseError(ParseErrorType.RegexMatchIssue,
$"Question match at index {i} does not have enough regex groups or valid number/text groups (Group 1/2). Skipping this question.",
index: i, matchedText: pm.MatchedText));
currentContentStart = pm.EndIndex; // 尝试跳过此项
continue;
}
float score = 0;
// 使用 float.TryParse 避免异常
if (pm.RegexMatch.Groups.Count > 4 && pm.RegexMatch.Groups[4].Success) // 假设纯数字分数是 Group 4
{
if (!float.TryParse(pm.RegexMatch.Groups[4].Value, out score))
{
examPaper.Errors.Add(new ParseError(ParseErrorType.DataParsing,
$"Failed to parse score '{pm.RegexMatch.Groups[4].Value}' for Question at index {i}. Defaulting to 0.",
index: i, matchedText: pm.MatchedText));
}
}
Question newQuestion = new Question
{
Number = pm.RegexMatch.Groups[1].Value.Trim(),
Stem = pm.RegexMatch.Groups[2].Value.Trim(),
Priority = pm.PatternConfig.Priority,
Score = score // 赋值解析到的分数
};
if (questionStack.Any())
{
questionStack.Peek().SubQuestions.Add(newQuestion);
}
else if (currentMajorQG != null)
{
currentMajorQG.SubQuestions.Add(newQuestion);
}
else
{
examPaper.TopLevelQuestions.Add(newQuestion);
}
currentContentStart = pm.EndIndex;
questionStack.Push(newQuestion);
currentQuestion = newQuestion;
}
catch (Exception ex)
{
examPaper.Errors.Add(new ParseError(ParseErrorType.UnexpectedError,
$"An unexpected error occurred during processing Question at index {i}.",
index: i, matchedText: pm.MatchedText, innerException: ex));
currentContentStart = pm.EndIndex; // 尝试跳过此项
continue;
}
}
else if (pm.Type == MatchType.Option)
{
// 对 Option 的处理
try
{
if (currentQuestion != null)
{
// RegexMatch Groups 验证
if (pm.RegexMatch.Groups.Count < 3 || string.IsNullOrWhiteSpace(pm.RegexMatch.Groups[1].Value) || string.IsNullOrWhiteSpace(pm.RegexMatch.Groups[2].Value))
{
examPaper.Errors.Add(new ParseError(ParseErrorType.RegexMatchIssue,
$"Option match at index {i} does not have enough regex groups or valid label/text groups (Group 1/2). Skipping this option.",
index: i, matchedText: pm.MatchedText));
currentContentStart = pm.EndIndex; // 尝试跳过此项
continue;
}
Option newOption = new Option
{
Label = pm.RegexMatch.Groups[1].Value.Trim(),
Text = pm.RegexMatch.Groups[2].Value.Trim()
};
currentQuestion.Options.Add(newOption);
}
else
{
// 结构性问题:找到孤立的选项,记录错误但继续
examPaper.Errors.Add(new ParseError(ParseErrorType.Structural,
$"Found isolated Option at index {i}. Options must belong to a question. Ignoring this option.",
index: i, matchedText: pm.MatchedText));
}
}
catch (Exception ex)
{
examPaper.Errors.Add(new ParseError(ParseErrorType.UnexpectedError,
$"An unexpected error occurred during processing Option at index {i}.",
index: i, matchedText: pm.MatchedText, innerException: ex));
// 这里不需要 `continue`,因为即使出错也可能只是该选项的问题,不影响后续处理
}
}
currentContentStart = pm.EndIndex; // 更新当前内容起点
}
catch (Exception ex)
{
// 捕获任何在处理单个 PotentialMatch 过程中未被更具体 catch 块捕获的意外错误
examPaper.Errors.Add(new ParseError(ParseErrorType.UnexpectedError,
$"An unexpected error occurred during main loop processing of PotentialMatch at index {i}.",
index: i, matchedText: pm.MatchedText, innerException: ex));
currentContentStart = Math.Max(currentContentStart, pm.EndIndex); // 尝试跳过当前匹配项,继续下一项
// 这里不 `continue` 是因为外层循环会推进 `i`,但确保 `currentContentStart` 更新以避免无限循环
}
}
// --- 处理所有匹配项之后的剩余内容 ---
if (currentContentStart < fullExamText.Length)
{
try
{
string remainingText = fullExamText.Substring(currentContentStart).Trim();
if (!string.IsNullOrWhiteSpace(remainingText))
{
if (currentQuestion != null)
{
ProcessQuestionContent(currentQuestion, remainingText,
GetSubMatchesForRange(allPotentialMatches, currentContentStart, fullExamText.Length, examPaper.Errors),
examPaper.Errors);
}
else if (currentMajorQG != null)
{
currentMajorQG.Descript += (string.IsNullOrWhiteSpace(currentMajorQG.Descript) ? "" : "\n") + remainingText;
}
else
{
examPaper.Description += (string.IsNullOrWhiteSpace(examPaper.Description) ? "" : "\n") + remainingText;
}
}
}
catch (Exception ex)
{
examPaper.Errors.Add(new ParseError(ParseErrorType.UnexpectedError,
"An unexpected error occurred while processing remaining text after all potential matches.",
innerException: ex));
}
}
return examPaper;
}
/// <summary>
/// Extracts the exam title (simple implementation).
/// Logs errors to the provided error list instead of throwing.
/// </summary>
private string GetExamTitle(string examPaperText)
{
// 内部不再直接抛出异常,而是让外部的 try-catch 负责
var firstLine = examPaperText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
return firstLine ?? "未识别试卷标题";
}
/// <summary>
/// Gets a subset of the given PotentialMatch list within a specified range.
/// Logs errors to the provided error list instead of throwing.
/// </summary>
private List<PotentialMatch> GetSubMatchesForRange(List<PotentialMatch> allMatches, int start, int end, List<ParseError> errors)
{
// 输入验证,如果输入错误,记录错误并返回空列表
if (start < 0 || end < start)
{
errors.Add(new ParseError(ParseErrorType.Validation,
$"Invalid range provided to GetSubMatchesForRange. Start: {start}, End: {end}.",
index: start)); // 使用 start 作为大概索引
return new List<PotentialMatch>();
}
// allMatches 为 null 的情况已经在 BuildExamPaper 顶部处理,这里为了方法的健壮性可以再加一次检查
if (allMatches == null)
{
return new List<PotentialMatch>();
}
try
{
return allMatches.Where(pm => pm.StartIndex >= start && pm.StartIndex < end).ToList();
}
catch (Exception ex)
{
errors.Add(new ParseError(ParseErrorType.UnexpectedError,
$"An unexpected error occurred getting sub-matches for range [{start}, {end}).",
innerException: ex));
return new List<PotentialMatch>(); // 出错时返回空列表
}
}
/// <summary>
/// Processes the content of a Question, mainly for parsing Options and identifying unstructured text.
/// Logs errors to the provided error list instead of throwing.
/// </summary>
private void ProcessQuestionContent(Question question, string contentText, List<PotentialMatch> potentialMatchesInScope, List<ParseError> errors)
{
// 参数验证,这些是内部方法的契约,如果违反则直接抛出,因为这意味着调用者有错
if (question == null) throw new ArgumentNullException(nameof(question), "Question cannot be null in ProcessQuestionContent.");
if (contentText == null) throw new ArgumentNullException(nameof(contentText), "Content text cannot be null in ProcessQuestionContent.");
if (potentialMatchesInScope == null) throw new ArgumentNullException(nameof(potentialMatchesInScope), "Potential matches in scope cannot be null.");
try
{
int lastOptionEndIndex = 0;
foreach (var pm in potentialMatchesInScope.OrderBy(p => p.StartIndex))
{
// 对每个匹配项的内部处理,记录错误但继续
try
{
if (pm.Type == MatchType.Option)
{
// 验证索引,记录错误但继续
if (pm.StartIndex < lastOptionEndIndex || pm.StartIndex > contentText.Length || pm.EndIndex > contentText.Length)
{
errors.Add(new ParseError(ParseErrorType.Validation,
$"Option match at index {pm.StartIndex} has invalid indices within content text. MatchedText: '{pm.MatchedText}'. Skipping.",
index: pm.StartIndex, matchedText: pm.MatchedText));
continue; // 跳过当前选项
}
// 处理选项前的文本
if (pm.StartIndex > lastOptionEndIndex)
{
string textBeforeOption = contentText.Substring(lastOptionEndIndex, pm.StartIndex - lastOptionEndIndex).Trim();
if (!string.IsNullOrWhiteSpace(textBeforeOption))
{
question.Stem += (string.IsNullOrWhiteSpace(question.Stem) ? "" : "\n") + textBeforeOption;
}
}
// RegexMatch Groups 验证,记录错误但继续
if (pm.RegexMatch.Groups.Count < 3 || string.IsNullOrWhiteSpace(pm.RegexMatch.Groups[1].Value) || string.IsNullOrWhiteSpace(pm.RegexMatch.Groups[2].Value))
{
errors.Add(new ParseError(ParseErrorType.RegexMatchIssue,
$"Option regex match '{pm.MatchedText}' does not have enough groups (expected 3) for label and text. Skipping option.",
index: pm.StartIndex, matchedText: pm.MatchedText));
lastOptionEndIndex = pm.EndIndex; // 更新索引,避免卡死
continue; // 跳过当前选项
}
var newOption = new Option
{
Label = pm.RegexMatch.Groups[1].Value.Trim(),
Text = pm.RegexMatch.Groups[2].Value.Trim()
};
question.Options.Add(newOption);
lastOptionEndIndex = pm.EndIndex;
}
// TODO: If there are SubQuestion types, they can be processed similarly here.
// 你可以在此处添加对子问题的处理逻辑,同样需要小心处理其内容和嵌套。
}
catch (Exception innerEx)
{
errors.Add(new ParseError(ParseErrorType.UnexpectedError,
$"An unexpected error occurred during processing a potential match ({pm.Type}) within question content.",
index: pm.StartIndex, matchedText: pm.MatchedText, innerException: innerEx));
lastOptionEndIndex = pm.EndIndex; // 尝试更新索引,避免无限循环
continue; // 尝试继续下一个匹配项
}
}
// 处理所有选项之后的剩余文本
if (lastOptionEndIndex < contentText.Length)
{
string remainingContent = contentText.Substring(lastOptionEndIndex).Trim();
if (!string.IsNullOrWhiteSpace(remainingContent))
{
question.Stem += (string.IsNullOrWhiteSpace(question.Stem) ? "" : "\n") + remainingContent;
}
}
}
catch (Exception ex)
{
// 捕获 ProcessQuestionContent 整个方法内部的意外错误
errors.Add(new ParseError(ParseErrorType.UnexpectedError,
$"An unexpected error occurred while processing content for Question '{question.Number}'.",
innerException: ex));
}
}
}
public class ExamParser
{
private readonly ExamParserConfig _config;
private readonly ExamDocumentScanner _scanner;
private readonly ExamStructureBuilder _builder;
public ExamParser(ExamParserConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_scanner = new ExamDocumentScanner(_config);
_builder = new ExamStructureBuilder(_config);
}
/// <summary>
/// 解析给定的试卷文本,返回结构化的 ExamPaper 对象。
/// </summary>
/// <param name="examPaperText">完整的试卷文本</param>
/// <returns>解析后的 ExamPaper 对象</returns>
public ExamPaper ParseExamPaper(string examPaperText)
{
// 1. 扫描:一次性扫描整个文本,收集所有潜在的匹配项
// Scan 方法现在已经优化为不抛出 ArgumentNullException
List<PotentialMatch> allPotentialMatches = _scanner.Scan(examPaperText);
// 2. 构建:根据扫描结果和原始文本,线性遍历并构建层级结构
// BuildExamPaper 现在会返回一个包含错误列表的 ExamPaper 对象
// 外部不再需要捕获内部解析异常,只需检查 ExamPaper.Errors 列表
return _builder.BuildExamPaper(examPaperText, allPotentialMatches);
}
}
}

View File

@@ -2,6 +2,9 @@
using TechHelper.Client.AI; using TechHelper.Client.AI;
using TechHelper.Services; using TechHelper.Services;
using Entities.DTO; using Entities.DTO;
using System.Net.Http.Json;
using Newtonsoft.Json;
using TechHelper.Client.Pages.Exam;
namespace TechHelper.Client.Exam namespace TechHelper.Client.Exam
@@ -9,10 +12,13 @@ namespace TechHelper.Client.Exam
public class ExamService : IExamService public class ExamService : IExamService
{ {
private IAIService aIService; private IAIService aIService;
private IHttpClientFactory httpClientFactory;
public ExamService(IAIService aIService) public ExamService(IAIService aIService,
IHttpClientFactory httpClientFactory)
{ {
this.aIService = aIService; this.aIService = aIService;
this.httpClientFactory = httpClientFactory;
} }
public ApiResponse ConvertToXML<T>(string xmlContent) public ApiResponse ConvertToXML<T>(string xmlContent)
@@ -86,7 +92,7 @@ namespace TechHelper.Client.Exam
{ {
Status = false, Status = false,
Result = null, Result = null,
Message = $"处理试题分割时发生内部错误: {ex.Message}" Message = $"处理试题分割时发生内部错误: {ex.Message}"
}; };
} }
} }
@@ -127,6 +133,31 @@ namespace TechHelper.Client.Exam
} }
} }
public async Task<ApiResponse> GetAllExam(string user)
{
using (var client = httpClientFactory.CreateClient("Default"))
{
var response = await client.GetAsync($"exam/getAllPreview?user={user}");
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<List<ExamDto>>(content);
return ApiResponse.Success(result: result);
}
else
{
return ApiResponse.Error(await response.Content.ReadAsStringAsync());
}
}
}
public async Task<ApiResponse> GetExam(Guid guid)
{
return ApiResponse.Success("HELLO");
}
public async Task<ApiResponse> ParseSingleQuestionGroup(string examContent) public async Task<ApiResponse> ParseSingleQuestionGroup(string examContent)
{ {
try try
@@ -163,9 +194,19 @@ namespace TechHelper.Client.Exam
} }
} }
public Task<ApiResponse> SaveParsedExam(ExamDto examDto) public async Task<ApiResponse> SaveParsedExam(ExamDto examDto)
{ {
throw new NotImplementedException(); using (var client = httpClientFactory.CreateClient("Default"))
{
var respont = await client.PostAsJsonAsync("exam/add",
examDto);
if (respont.StatusCode == System.Net.HttpStatusCode.OK)
{
return new ApiResponse(true, "ok");
}
return new ApiResponse("false");
}
} }
} }
} }

View File

@@ -10,5 +10,8 @@ namespace TechHelper.Client.Exam
public Task<ApiResponse> SaveParsedExam(ExamDto examDto); public Task<ApiResponse> SaveParsedExam(ExamDto examDto);
public Task<ApiResponse> ParseSingleQuestionGroup(string examContent); public Task<ApiResponse> ParseSingleQuestionGroup(string examContent);
public ApiResponse ConvertToXML<T>(string xmlContent); public ApiResponse ConvertToXML<T>(string xmlContent);
public Task<ApiResponse> GetAllExam(string user);
public Task<ApiResponse> GetExam(Guid guid);
} }
} }

View File

@@ -31,7 +31,6 @@ namespace BlazorProducts.Client.HttpInterceptor
{ {
var absolutePath = request.RequestUri?.AbsolutePath; var absolutePath = request.RequestUri?.AbsolutePath;
_serviceProvider.Add("HELLO");
if (absolutePath != null && !absolutePath.Contains("token") && !absolutePath.Contains("account")) if (absolutePath != null && !absolutePath.Contains("token") && !absolutePath.Contains("account"))
{ {
var token = await _refreshTokenService.TryRefreshToken(); var token = await _refreshTokenService.TryRefreshToken();

View File

@@ -4,7 +4,7 @@
<MudSnackbarProvider /> <MudSnackbarProvider />
<MudPopoverProvider /> <MudPopoverProvider />
@*
<MudPaper Style="position: fixed; <MudPaper Style="position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@@ -48,4 +48,39 @@
</MudPaper> </MudPaper>
</MudPaper>
*@
<MudPaper Style="position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-image: url('/ref/bg4.jpg');
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
filter: blur(10px);
z-index: -1;">
</MudPaper>
<MudPaper Style="background-color:transparent ; height:100vh" Class="overflow-hidden">
<MudPaper Class="justify-content-center" Style="background-color:blue; height: 50px">
<MudStack Row="true" Class="justify-content-between">
<NavBar Class="flex-grow-1" Style="background-color:transparent; color:white" />
<AuthLinks Class="justify-content-end " Style="background-color:transparent; color:white" />
</MudStack>
</MudPaper>
<MudPaper Class="d-flex flex-grow-0 " Style="background-color:#30303022; height:calc(100vh - 50px)">
@* <MudPaper Class="ma-1" Width="200px">
</MudPaper> *@
<MudPaper Class="d-flex ma-1 flex-grow-1 overflow-auto">
@Body
</MudPaper>
</MudPaper>
</MudPaper> </MudPaper>

View File

@@ -2,9 +2,9 @@
<MudStack Row="true"> <MudStack Row="true">
<MudNavLink Class="py-5 px-3" Href="" Match="NavLinkMatch.All"> 主页 </MudNavLink> <MudNavLink Class="py-5 px-3" Href="" Match="NavLinkMatch.All"> 主页 </MudNavLink>
<MudNavLink Class="py-5 px-3" Href="Account/Manage"> 个人中心 </MudNavLink> <MudNavLink Class="py-5 px-3" Href="Account/Manage"> 个人中心 </MudNavLink>
<MudNavLink Class="py-5 px-3" Href="Edit"> 编辑器 </MudNavLink> <MudNavLink Class="py-5 px-3" Href="exam"> Exam </MudNavLink>
<MudNavLink Class="py-5 px-3" Href="ai"> AI </MudNavLink> <MudNavLink Class="py-5 px-3" Href="ai"> AI </MudNavLink>
<MudNavLink Class="py-5 px-3" Href="test"> 测试页面 </MudNavLink> <MudNavLink Class="py-5 px-3" Href="test"> Test </MudNavLink>
</MudStack> </MudStack>
</MudPaper> </MudPaper>

View File

@@ -4,6 +4,6 @@
@code { @code {
protected override void OnInitialized() protected override void OnInitialized()
{ {
Navigation.NavigateToLogin("authentication/login"); Navigation.NavigateToLogin("/login");
} }
} }

View File

@@ -4,10 +4,6 @@
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="@(() => Snackbar.Add("Simple Snackbar"))">
Open Snackbar
</MudButton>
<MudText Typo="Typo.h2"> Create Account </MudText> <MudText Typo="Typo.h2"> Create Account </MudText>

View File

@@ -1,50 +0,0 @@
@page "/test"
<MudPaper Class="d-flex flex-column justify-space-around flex-grow-1 overflow-scroll">
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
<MudText>HELLO </MudText>
</MudPaper>
@code {
}

View File

@@ -0,0 +1,50 @@
@using Blazored.TextEditor
<MudPaper Height="@Height" Class="@Class" Style="@Style">
<MudPaper Elevation="0" Style="height:calc(100% - 50px)">
<BlazoredTextEditor @ref="@BlazorTextEditor">
<ToolbarContent>
<select class="ql-header">
<option selected=""></option>
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
<option value="4"></option>
<option value="5"></option>
</select>
<span class="ql-formats">
<button class="ql-bold"></button>
<button class="ql-italic"></button>
<button class="ql-underline"></button>
<button class="ql-strike"></button>
</span>
<span class="ql-formats">
<select class="ql-color"></select>
<select class="ql-background"></select>
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered"></button>
<button class="ql-list" value="bullet"></button>
</span>
<span class="ql-formats">
<button class="ql-link"></button>
</span>
</ToolbarContent>
<EditorContent>
</EditorContent>
</BlazoredTextEditor>
</MudPaper>
</MudPaper>
@code {
[Parameter]
public BlazoredTextEditor BlazorTextEditor { get; set; }
[Parameter]
public string Height { get; set; } = "100%";
[Parameter]
public string Class { get; set; } = "";
[Parameter]
public string Style { get; set; } = "";
}

View File

@@ -1 +0,0 @@
<h3>ChoiceQuestion</h3>

View File

@@ -0,0 +1,132 @@
@page "/exam/create"
@using Blazored.TextEditor
@using Entities.DTO
@using TechHelper.Client.Exam
@inject ILogger<Home> Logger
@inject ISnackbar Snackbar;
@using Microsoft.AspNetCore.Components
@using System.Globalization;
@using TechHelper.Client.Pages.Editor
<MudPaper Elevation="5" Class="d-flex overflow-hidden flex-grow-1" Style="overflow:hidden; position:relative;height:100%">
<MudDrawerContainer Class="mud-height-full flex-grow-1" Style="height:100%">
<MudDrawer @bind-Open="@_open" Elevation="0" Variant="@DrawerVariant.Persistent" Color="Color.Primary" Anchor="Anchor.End" OverlayAutoClose="true">
<MudDrawerHeader>
<MudText Typo="Typo.h6"> 配置 </MudText>
</MudDrawerHeader>
<MudStack Class="overflow-auto">
<ParseRoleConfig />
<MudButton Color="Color.Success"> ParseExam </MudButton>
</MudStack>
</MudDrawer>
<MudStack Row="true" Class="flex-grow-1" Style="height:100%">
<ExamView Class="overflow-auto" ParsedExam="ExamContent"></ExamView>
<MudPaper Class="ma-2">
<MudPaper Elevation="0" Style="height:calc(100% - 80px)">
<BlazoredTextEditor @ref="@_textEditor">
<ToolbarContent>
<select class="ql-header">
<option selected=""></option>
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
<option value="4"></option>
<option value="5"></option>
</select>
<span class="ql-formats">
<button class="ql-bold"></button>
<button class="ql-italic"></button>
<button class="ql-underline"></button>
<button class="ql-strike"></button>
</span>
<span class="ql-formats">
<select class="ql-color"></select>
<select class="ql-background"></select>
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered"></button>
<button class="ql-list" value="bullet"></button>
</span>
<span class="ql-formats">
<button class="ql-link"></button>
</span>
</ToolbarContent>
<EditorContent>
</EditorContent>
</BlazoredTextEditor>
</MudPaper>
</MudPaper>
<MudButtonGroup Vertical="true" Color="Color.Primary" Variant="Variant.Filled">
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@ToggleDrawer" Color="Color.Secondary" />
<MudIconButton Icon="@Icons.Material.Filled.TransitEnterexit" OnClick="@ParseExam" Color="Color.Secondary" />
<MudIconButton Icon="@Icons.Material.Filled.Save" OnClick="@ToggleDrawer" Color="Color.Secondary" />
<MudIconButton Icon="@Icons.Material.Filled.Publish" OnClick="@Publish" Color="Color.Secondary" />
</MudButtonGroup>
</MudStack>
</MudDrawerContainer>
</MudPaper>
@code {
[CascadingParameter]
private Task<AuthenticationState> authenticationStateTask { get; set; }
private bool _open = false;
private void ToggleDrawer()
{
_open = !_open;
}
private BlazoredTextEditor _textEditor = new BlazoredTextEditor();
private ExamPaper _parsedExam = new ExamPaper();
private ExamDto ExamContent = new ExamDto();
private ExamParserConfig _examParserConfig { get; set; } = new ExamParserConfig();
private async Task ParseExam()
{
var plainText = await _textEditor.GetText();
if (!string.IsNullOrWhiteSpace(plainText))
{
try
{
var exampar = new ExamParser(_examParserConfig);
_parsedExam = exampar.ParseExamPaper(plainText);
Snackbar.Add("试卷解析成功。", Severity.Success);
Snackbar.Add($"{_parsedExam.Errors}。", Severity.Success);
ExamContent = _parsedExam.ConvertToExamDTO();
}
catch (Exception ex)
{
Console.WriteLine($"Error parsing exam paper: {ex.Message}");
Console.WriteLine($"Stack Trace: {ex.StackTrace}");
Snackbar.Add($"解析试卷时发生错误:{ex.Message}", Severity.Error);
}
}
else
{
Snackbar.Add("试卷文本为空,无法解析。", Severity.Warning);
}
StateHasChanged();
}
[Inject]
public IExamService examService { get; set; }
public async Task Publish()
{
ExamContent.CreaterEmail = authenticationStateTask.Result.User.Identity.Name;
var apiRespon = await examService.SaveParsedExam(ExamContent);
Snackbar.Add(apiRespon.Message);
}
}

View File

@@ -0,0 +1,15 @@
@page "/exam/edit/{ExamId:Guid}"
@using TechHelper.Client.Exam
@code {
[Parameter]
public Guid ExamId { get; set; }
[Inject]
public IExamService ExamService { get; set; }
protected override async Task OnInitializedAsync()
{
await ExamService.GetExam(Guid.NewGuid());
}
}

View File

@@ -0,0 +1,48 @@
@using Entities.DTO
@using TechHelper.Client.Exam
<MudPaper Elevation=@Elevation Class=@Class>
@foreach (var majorQG in MajorQGList)
{
<MudStack Row="true">
<MudText Typo="Typo.h6">@majorQG.Title</MudText>
@if (majorQG.Score > 0)
{
<MudText Typo="Typo.body2"><b>总分:</b> @majorQG.Score 分</MudText>
}
</MudStack>
@if (!string.IsNullOrWhiteSpace(majorQG.Descript))
{
<MudText Typo="Typo.body2">@((MarkupString)majorQG.Descript.Replace("\n", "<br />"))</MudText>
}
@if (majorQG.SubQuestions.Any())
{
@foreach (var question in majorQG.SubQuestions)
{
<QuestionCard Question="question" Elevation=@Elevation Class="my-2 pa-1" />
}
}
@if (majorQG.SubQuestionGroups.Any())
{
<ExamGroupView MajorQGList="majorQG.SubQuestionGroups" Elevation="1" />
}
}
</MudPaper>
@code {
[Parameter]
public List<QuestionGroupDto> MajorQGList { get; set; }
[Parameter]
public string Class { get; set; } = "my-2 pa-1";
[Parameter]
public int Elevation { get; set; } = 0;
}

View File

@@ -0,0 +1,55 @@
@using Entities.DTO
@using Microsoft.AspNetCore.Authorization
@using TechHelper.Client.Exam
@page "/exam/manage"
@attribute [Authorize]
@if (isloding)
{
<MudText> 正在加载 </MudText>
}
else
{
}
@foreach (var item in examDtos)
{
<ExamPreview examDto="item"> </ExamPreview>
}
@code {
[Inject]
public IExamService ExamService { get; set; }
[Inject]
public ISnackbar Snackbar { get; set; }
[CascadingParameter]
private Task<AuthenticationState> authenticationStateTask { get; set; }
private List<ExamDto> examDtos = new List<ExamDto>();
private bool isloding = true;
protected override async Task OnInitializedAsync()
{
GetExam();
}
private async void GetExam()
{
isloding = true;
Snackbar.Add("正在加载", Severity.Info);
var result = await ExamService.GetAllExam(authenticationStateTask.Result.User.Identity.Name);
examDtos = result.Result as List<ExamDto> ?? new List<ExamDto>();
isloding = false;
Snackbar.Add("加载成功", Severity.Info);
StateHasChanged();
}
}

View File

@@ -0,0 +1,37 @@
@using Entities.DTO
<MudPaper Width="@Width" Height="@Height" @onclick="ExamClick">
<MudCard>
<MudCardHeader>
<MudText> @examDto.AssignmentTitle </MudText>
</MudCardHeader>
<MudCardContent>
<MudText> @examDto.Description </MudText>
</MudCardContent>
</MudCard>
</MudPaper>
@code {
[Inject]
public NavigationManager navigationManager { get; set; }
[Parameter]
public ExamDto examDto { get; set; }
[Parameter]
public string? Width { get; set; } = "200";
[Parameter]
public string? Height { get; set; } = "400";
private void ExamClick()
{
navigationManager.NavigateTo($"exam/Edit/{examDto.AssignmentId}");
}
}

View File

@@ -0,0 +1,23 @@
@using Entities.DTO
@using TechHelper.Client.Exam
<MudPaper Height="@Height" Class="@Class" Style="@Style" Width="@Width">
<MudText Class="d-flex justify-content-center" Typo="Typo.h6"> @ParsedExam.AssignmentTitle </MudText>
<MudText Typo="Typo.body1"> @ParsedExam.Description </MudText>
<ExamGroupView MajorQGList="@ParsedExam.QuestionGroups.SubQuestionGroups" Elevation="1" Class="ma-0 pa-2" />
</MudPaper>
@code {
[Parameter]
public ExamDto ParsedExam { get; set; } = new ExamDto();
[Parameter]
public string Height { get; set; } = "100%";
[Parameter]
public string Width { get; set; } = "100%";
[Parameter]
public string Class { get; set; } = "";
[Parameter]
public string Style { get; set; } = "";
}

View File

@@ -0,0 +1,7 @@
@page "/exam"
<MudText>HELLO WORLD</MudText>
@code {
}

View File

@@ -0,0 +1,127 @@
@using TechHelper.Client.Exam
<MudPaper Outlined="true" Class="mt-2">
<MudRadioGroup @bind-Value="_examParser">
@foreach (ExamParserEnum item in Enum.GetValues(typeof(ExamParserEnum)))
{
<MudRadio T="ExamParserEnum" Value="@item">@item</MudRadio>
}
</MudRadioGroup>
<MudTextField @bind-Value="_ParserConfig" Label="正则表达式模式" Variant="Variant.Outlined" FullWidth="true" Class="mb-2" />
<MudNumericField Label="优先级" @bind-Value="_Priority" Variant="Variant.Outlined" Min="1" Max="100" />
<MudButton OnClick="AddPattern" Variant="Variant.Filled" Color="Color.Primary" Class="mt-2">添加模式</MudButton>
<MudText Typo="Typo.subtitle1" Class="mb-2">所有已配置模式:</MudText>
@if (ExamParserConfig.MajorQuestionGroupPatterns.Any())
{
<MudExpansionPanel Text="大题组模式详情" Class="mb-2">
<MudStack>
@foreach (var config in ExamParserConfig.MajorQuestionGroupPatterns)
{
<MudChip T="string">
**模式:** <code>@config.Pattern</code>, **优先级:** @config.Priority
</MudChip>
}
</MudStack>
</MudExpansionPanel>
}
else
{
<MudText Typo="Typo.body2" Class="mb-2">暂无大题组模式。</MudText>
}
@* 题目模式详情 *@
@if (ExamParserConfig.QuestionPatterns.Any())
{
<MudExpansionPanel Text="题目模式详情" Class="mb-2">
<MudStack>
@foreach (var config in ExamParserConfig.QuestionPatterns)
{
<MudChip T="string">
**模式:** <code>@config.Pattern</code>, **优先级:** @config.Priority
</MudChip>
}
</MudStack>
</MudExpansionPanel>
}
else
{
<MudText Typo="Typo.body2" Class="mb-2">暂无题目模式。</MudText>
}
@if (ExamParserConfig.OptionPatterns.Any())
{
<MudExpansionPanel Text="选项模式详情" Class="mb-2">
<MudStack>
@foreach (var config in ExamParserConfig.OptionPatterns)
{
<MudChip T="string">
**模式:** <code>@config.Pattern</code>, **优先级:** @config.Priority
</MudChip>
}
</MudStack>
</MudExpansionPanel>
}
else
{
<MudText Typo="Typo.body2" Class="mb-2">暂无选项模式。</MudText>
}
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="ResetPatterns">重置默认规则</MudButton>
</MudPaper>
@code {
public ExamParserEnum _examParser { get; set; } = ExamParserEnum.MajorQuestionGroupPatterns;
private string _ParserConfig;
private int _Priority = 1;
[Parameter]
public ExamParserConfig ExamParserConfig { get; set; } = new ExamParserConfig();
[Inject]
public ISnackbar Snackbar { get; set; }
private void AddPattern()
{
switch ((ExamParserEnum)_examParser)
{
case ExamParserEnum.MajorQuestionGroupPatterns:
ExamParserConfig.MajorQuestionGroupPatterns.Add(new RegexPatternConfig(_ParserConfig, _Priority));
Snackbar.Add($"已添加大题组模式: {_ParserConfig}, 优先级: {_Priority}", Severity.Success);
break;
case ExamParserEnum.QuestionPatterns:
ExamParserConfig.QuestionPatterns.Add(new RegexPatternConfig(_ParserConfig, _Priority));
Snackbar.Add($"已添加题目模式: {_ParserConfig}, 优先级: {_Priority}", Severity.Success);
break;
case ExamParserEnum.OptionPatterns:
ExamParserConfig.OptionPatterns.Add(new RegexPatternConfig(_ParserConfig, _Priority));
Snackbar.Add($"已添加选项模式: {_ParserConfig}, 优先级: {_Priority}", Severity.Success);
break;
default:
Snackbar.Add("请选择要添加的模式类型。");
break;
}
StateHasChanged();
}
private void ResetPatterns()
{
ExamParserConfig = new ExamParserConfig();
StateHasChanged();
}
}

View File

@@ -1,11 +0,0 @@
<MudText> @Title </MudText>
@code {
[Parameter]
public string Title { get; set; }
[Parameter]
public string Answer { get; set; }
}

View File

@@ -0,0 +1,36 @@
@using Entities.DTO
@using TechHelper.Client.Exam
<MudPaper Class=@Class Elevation=@Elevation Outlined="false">
<MudText Typo="Typo.subtitle1">
<b>@Question.Index</b> @((MarkupString)Question.Stem.Replace("\n", "<br />"))
@if (Question.Score > 0)
{
<MudText Typo="Typo.body2" Class="d-inline ml-2">(@Question.Score 分)</MudText>
}
</MudText>
@if (Question.Options.Any())
{
<div class="mt-2">
@foreach (var option in Question.Options)
{
var tempOption = option;
<p>@((MarkupString)(tempOption.Value.Replace("\n", "<br />")))</p>
}
</div>
}
</MudPaper>
@code {
[Parameter]
public SubQuestionDto Question { get; set; }
[Parameter]
public string Class { get; set; }
[Parameter]
public int Elevation { get; set; }
}

View File

@@ -1,4 +0,0 @@

@code {
}

View File

@@ -1,69 +0,0 @@
@using TechHelper.Client.Exam
<MudCard Class="@(IsNested ? "mb-3 pa-2" : "my-4")" Outlined="@IsNested">
@if (QuestionGroup.Title != string.Empty)
{
<MudCardHeader>
<MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudText Typo="@(IsNested ? Typo.h6 : Typo.h5)">@QuestionGroup.Id. </MudText> @* 嵌套时字号稍小 *@
<MudText Typo="@(IsNested ? Typo.h6 : Typo.h5)">@QuestionGroup.Title</MudText>
</MudStack>
@if (!string.IsNullOrEmpty(QuestionGroup.QuestionReference))
{
<MudText Class="mt-2" Style="white-space: pre-wrap;">@QuestionGroup.QuestionReference</MudText>
}
</MudStack>
</MudCardHeader>
}
<MudCardContent>
@* 渲染直接子题目 *@
@if (QuestionGroup.SubQuestions != null && QuestionGroup.SubQuestions.Any())
{
@foreach (var qitem in QuestionGroup.SubQuestions)
{
<MudStack Row="true" AlignItems="AlignItems.Baseline" Class="mb-2">
<MudText Typo="Typo.body1">@qitem.SubId. </MudText>
<MudText Typo="Typo.body1">@qitem.Stem</MudText>
</MudStack>
@if (qitem.Options != null && qitem.Options.Any())
{
@foreach (var oitem in qitem.Options)
{
<MudText Typo="Typo.body2" Class="ml-6 mb-2">@oitem.Value</MudText>
}
}
@if (!string.IsNullOrEmpty(qitem.SampleAnswer))
{
<MudText Typo="Typo.body2" Color="Color.Tertiary" Class="ml-6 mb-2">示例答案: @qitem.SampleAnswer</MudText>
}
}
}
@* 递归渲染子题组 *@
@if (QuestionGroup.SubQuestionGroups != null && QuestionGroup.SubQuestionGroups.Any())
{
<MudDivider Class="my-4" />
@if (!IsNested) // 只有顶级大题才显示“嵌套题组”标题
{
<MudText Typo="Typo.subtitle1" Class="mb-2">相关题组:</MudText>
}
@foreach (var subGroup in QuestionGroup.SubQuestionGroups)
{
<QuestionGroupDisplay QuestionGroup="subGroup" IsNested="true" /> @* 递归调用自身 *@
}
}
</MudCardContent>
</MudCard>
@code {
[Parameter]
public TechHelper.Client.Exam.QuestionGroup QuestionGroup { get; set; } = new TechHelper.Client.Exam.QuestionGroup();
[Parameter]
public bool IsNested { get; set; } = false; // 判断是否是嵌套的题组,用于调整样式和显示标题
}

View File

@@ -0,0 +1,2 @@
@using TechHelper.Client.Shared
@layout ExamLayout

View File

@@ -19,3 +19,100 @@
</AuthorizeView> </AuthorizeView>
<MudText>Hello </MudText> <MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>
<MudText>Hello </MudText>

View File

@@ -38,6 +38,17 @@ builder.Services.AddScoped<RefreshTokenService>();
builder.Services.AddScoped<IClassServices, ClasssServices>(); builder.Services.AddScoped<IClassServices, ClasssServices>();
builder.Services.AddScoped<IEmailSender, QEmailSender>(); builder.Services.AddScoped<IEmailSender, QEmailSender>();
builder.Services.AddTransient<HttpInterceptorHandlerService>(); builder.Services.AddTransient<HttpInterceptorHandlerService>();
builder.Services.AddHttpClient("WebApiClient", client =>
{
var baseAddress = builder.Configuration.GetSection("ApiConfiguration:BaseAddress").Value;
client.BaseAddress = new Uri(baseAddress);
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("Accept", "application/json");
}).AddHttpMessageHandler<HttpInterceptorHandlerService>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("WebApiClient"));
builder.Services.AddHttpClient("Default", (sp, cl) => builder.Services.AddHttpClient("Default", (sp, cl) =>
{ {
var apiConfiguration = sp.GetRequiredService<IOptions<ApiConfiguration>>().Value; var apiConfiguration = sp.GetRequiredService<IOptions<ApiConfiguration>>().Value;

View File

@@ -0,0 +1,18 @@
@inherits LayoutComponentBase
@layout AccountLayout
<MudPaper Class="d-flex flex-row flex-grow-1 overflow-hidden" Height="100%">
<MudPaper Width="200px">
<h1>Manage your account</h1>
<h2>Change your account settings</h2>
<MudDivider Class="flex-grow-0" />
<ExamNavMenu />
</MudPaper>
<MudPaper Class="flex-grow-1 overflow-auto">
@Body
</MudPaper>
</MudPaper>

View File

@@ -0,0 +1,22 @@
@using Microsoft.AspNetCore.Identity
<ul class="nav nav-pills flex-column">
<li class="nav-item">
<NavLink class="nav-link" href="exam/create" Match="NavLinkMatch.All">创建</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="exam/manage">管理</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="Account/Manage/ChangePassword">Password</NavLink>
</li>
@* <li class="nav-item">
<NavLink class="nav-link" href="Account/Manage/TwoFactorAuthentication">Two-factor authentication</NavLink>
</li> *@
</ul>
@code {
private bool hasExternalLogins;
}

View File

@@ -13,6 +13,14 @@
<None Remove="Pages\Components\**" /> <None Remove="Pages\Components\**" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Remove="Pages\Editor\EditorMain.razor.cs" />
</ItemGroup>
<ItemGroup>
<Content Remove="Pages\Editor\EditorMain.razor" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Blazor.LocalStorage.WebAssembly" Version="8.0.0" /> <PackageReference Include="Blazor.LocalStorage.WebAssembly" Version="8.0.0" />
<PackageReference Include="Blazored.TextEditor" Version="1.1.3" /> <PackageReference Include="Blazored.TextEditor" Version="1.1.3" />

View File

@@ -1,4 +1,5 @@
using AutoMapper; using AutoMapper;
using AutoMapper.Internal.Mappers;
using Entities.Contracts; using Entities.Contracts;
using Entities.DTO; using Entities.DTO;
@@ -6,6 +7,19 @@ namespace TechHelper.Context
{ {
public class AutoMapperProFile : Profile public class AutoMapperProFile : Profile
{ {
public static class EnumMappingHelpers
{
public static TEnum ParseEnumSafe<TEnum>(string sourceString, TEnum defaultValue) where TEnum : struct, Enum
{
if (Enum.TryParse(sourceString, true, out TEnum parsedValue))
{
return parsedValue;
}
return defaultValue;
}
}
public AutoMapperProFile() public AutoMapperProFile()
{ {
CreateMap<UserForRegistrationDto, User>() CreateMap<UserForRegistrationDto, User>()
@@ -24,9 +38,9 @@ namespace TechHelper.Context
.ForMember(dest => dest.Id, opt => opt.Ignore()) .ForMember(dest => dest.Id, opt => opt.Ignore())
.ForMember(dest => dest.QuestionText, opt => opt.MapFrom(src => src.Stem)) .ForMember(dest => dest.QuestionText, opt => opt.MapFrom(src => src.Stem))
.ForMember(dest => dest.CorrectAnswer, opt => opt.MapFrom(src => src.SampleAnswer)) .ForMember(dest => dest.CorrectAnswer, opt => opt.MapFrom(src => src.SampleAnswer))
.ForMember(dest => dest.QuestionType, opt => opt.MapFrom(src => Enum.Parse<QuestionType>(src.QuestionType, true))) .ForMember(dest => dest.QuestionType, opt => opt.MapFrom(src => EnumMappingHelpers.ParseEnumSafe(src.QuestionType, QuestionType.Unknown)))
.ForMember(dest => dest.DifficultyLevel, opt => opt.MapFrom(src => Enum.Parse<DifficultyLevel>(src.DifficultyLevel, true))) .ForMember(dest => dest.DifficultyLevel, opt => opt.MapFrom(src => EnumMappingHelpers.ParseEnumSafe(src.DifficultyLevel, DifficultyLevel.easy)))
.ForMember(dest => dest.SubjectArea, opt => opt.Ignore()) // SubjectArea 来自 Assignment 而不是 SubQuestionDto .ForMember(dest => dest.SubjectArea, opt => opt.MapFrom(src => EnumMappingHelpers.ParseEnumSafe(src.DifficultyLevel, SubjectAreaEnum.Unknown)))
.ForMember(dest => dest.CreatedBy, opt => opt.Ignore()) .ForMember(dest => dest.CreatedBy, opt => opt.Ignore())
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) .ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
@@ -40,6 +54,13 @@ namespace TechHelper.Context
.ForMember(dest => dest.QuestionType, opt => opt.MapFrom(src => src.QuestionType.ToString())) .ForMember(dest => dest.QuestionType, opt => opt.MapFrom(src => src.QuestionType.ToString()))
.ForMember(dest => dest.DifficultyLevel, opt => opt.MapFrom(src => src.DifficultyLevel.ToString())) .ForMember(dest => dest.DifficultyLevel, opt => opt.MapFrom(src => src.DifficultyLevel.ToString()))
.ForMember(dest => dest.Options, opt => opt.Ignore()); // Options 需要单独处理 .ForMember(dest => dest.Options, opt => opt.Ignore()); // Options 需要单独处理
CreateMap<Assignment, ExamDto>()
.ForMember(dest => dest.AssignmentTitle, opt => opt.MapFrom(src => src.Title))
.ForMember(dest => dest.Description, opt => opt.MapFrom(src => src.Description))
.ForMember(dest => dest.AssignmentId, opt => opt.MapFrom(src=> src.Id))
.ForMember(dest => dest.SubjectArea, opt => opt.MapFrom(src => src.SubjectArea.ToString()));
} }
} }

View File

@@ -25,8 +25,7 @@ namespace TechHelper.Context.Configuration
// 配置 AssignmentId 属性对应的数据库列名为 "assignment",并设置为必需字段。 // 配置 AssignmentId 属性对应的数据库列名为 "assignment",并设置为必需字段。
builder.Property(ag => ag.AssignmentId) builder.Property(ag => ag.AssignmentId)
.HasColumnName("assignment") .HasColumnName("assignment");
.IsRequired();
// 配置 Title 属性对应的数据库列名为 "title",设置为必需字段,并设置最大长度。 // 配置 Title 属性对应的数据库列名为 "title",设置为必需字段,并设置最大长度。
builder.Property(ag => ag.Title) builder.Property(ag => ag.Title)
@@ -52,7 +51,8 @@ namespace TechHelper.Context.Configuration
// 配置 ParentGroup 属性对应的数据库列名为 "sub_group"。 // 配置 ParentGroup 属性对应的数据库列名为 "sub_group"。
// ParentGroup 是 Guid? 类型,默认就是可选的,无需 IsRequired(false)。 // ParentGroup 是 Guid? 类型,默认就是可选的,无需 IsRequired(false)。
builder.Property(ag => ag.ParentGroup) builder.Property(ag => ag.ParentGroup)
.HasColumnName("sub_group"); .HasColumnName("parent_group")
.IsRequired(false);
// 配置 IsDeleted 属性对应的数据库列名为 "deleted",并设置默认值为 false。 // 配置 IsDeleted 属性对应的数据库列名为 "deleted",并设置默认值为 false。
builder.Property(ag => ag.IsDeleted) builder.Property(ag => ag.IsDeleted)

View File

@@ -41,7 +41,7 @@ namespace TechHelper.Context.Configuration
// 配置 AssignmentGroupId 列 // 配置 AssignmentGroupId 列
// 该列在数据库中名为 "detail_id" // 该列在数据库中名为 "detail_id"
builder.Property(aq => aq.AssignmentGroupId) builder.Property(aq => aq.AssignmentGroupId)
.HasColumnName("detail_id") .HasColumnName("group_id")
.IsRequired(); .IsRequired();
// 配置 IsDeleted 列 // 配置 IsDeleted 列

View File

@@ -0,0 +1,74 @@
using Entities.Contracts;
using Entities.DTO;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using TechHelper.Server.Services;
using System.Security.Claims;
namespace TechHelper.Server.Controllers
{
[Route("api/exam")]
[ApiController]
[Authorize]
public class ExamController : ControllerBase
{
private IExamService _examService;
private readonly UserManager<User> _userManager;
public ExamController(IExamService examService, UserManager<User> userManager)
{
_examService = examService;
_userManager = userManager;
}
[HttpPost("add")]
public async Task<IActionResult> AddExam(
[FromBody] ExamDto examDto)
{
var result = await _examService.AddAsync(examDto);
if (result.Status)
{
return Ok(result);
}
else
{
return BadRequest();
}
}
[HttpGet("get")]
public async Task<IActionResult> GetExamById(Guid id)
{
var result = await _examService.GetAsync(id);
return Ok(result);
}
[HttpGet("getAllPreview")]
public async Task<IActionResult> GetAllExamPreview(string user)
{
string? userId = User.Identity.Name;
var userid = await _userManager.FindByEmailAsync(user);
if (userid == null) return BadRequest("用户验证失败, 无效用户");
var result = await _examService.GetAllExamPreview(userid.Id);
if (result.Status)
{
return Ok(result.Result);
}
return BadRequest(result);
}
}
}

View File

@@ -12,7 +12,7 @@ using TechHelper.Context;
namespace TechHelper.Server.Migrations namespace TechHelper.Server.Migrations
{ {
[DbContext(typeof(ApplicationContext))] [DbContext(typeof(ApplicationContext))]
[Migration("20250528090233_init")] [Migration("20250610025325_init")]
partial class init partial class init
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -158,7 +158,8 @@ namespace TechHelper.Server.Migrations
.HasColumnType("char(36)") .HasColumnType("char(36)")
.HasColumnName("id"); .HasColumnName("id");
b.Property<Guid>("AssignmentId") b.Property<Guid?>("AssignmentId")
.IsRequired()
.HasColumnType("char(36)") .HasColumnType("char(36)")
.HasColumnName("assignment"); .HasColumnName("assignment");
@@ -180,7 +181,7 @@ namespace TechHelper.Server.Migrations
b.Property<Guid?>("ParentGroup") b.Property<Guid?>("ParentGroup")
.HasColumnType("char(36)") .HasColumnType("char(36)")
.HasColumnName("sub_group"); .HasColumnName("parent_group");
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
@@ -188,10 +189,14 @@ namespace TechHelper.Server.Migrations
.HasColumnType("longtext") .HasColumnType("longtext")
.HasColumnName("title"); .HasColumnName("title");
b.Property<decimal?>("TotalPoints") b.Property<float?>("TotalPoints")
.HasColumnType("decimal(65,30)") .HasColumnType("float")
.HasColumnName("total_points"); .HasColumnName("total_points");
b.Property<bool>("ValidQuestionGroup")
.HasColumnType("tinyint(1)")
.HasColumnName("valid_question_group");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AssignmentId"); b.HasIndex("AssignmentId");
@@ -210,7 +215,7 @@ namespace TechHelper.Server.Migrations
b.Property<Guid>("AssignmentGroupId") b.Property<Guid>("AssignmentGroupId")
.HasColumnType("char(36)") .HasColumnType("char(36)")
.HasColumnName("detail_id"); .HasColumnName("group_id");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)") .HasColumnType("datetime(6)")
@@ -404,6 +409,10 @@ namespace TechHelper.Server.Migrations
.HasColumnType("datetime(6)") .HasColumnType("datetime(6)")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
b.Property<bool>("ValidQuestion")
.HasColumnType("tinyint(1)")
.HasColumnName("valid_question");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CreatedBy"); b.HasIndex("CreatedBy");
@@ -653,19 +662,19 @@ namespace TechHelper.Server.Migrations
b.HasData( b.HasData(
new new
{ {
Id = new Guid("ea0c88d8-1a52-4034-bb37-5a95043821eb"), Id = new Guid("9e526681-e57e-46b5-a01c-5731b27bfc4a"),
Name = "Student", Name = "Student",
NormalizedName = "STUDENT" NormalizedName = "STUDENT"
}, },
new new
{ {
Id = new Guid("9de22e41-c096-4d5a-b55a-ce0122aa3ada"), Id = new Guid("dfdfb884-4063-4161-84e0-9c225f4e883c"),
Name = "Teacher", Name = "Teacher",
NormalizedName = "TEACHER" NormalizedName = "TEACHER"
}, },
new new
{ {
Id = new Guid("dee718d9-b731-485f-96bb-a59ce777870f"), Id = new Guid("02a808ba-bd16-4f90-bf2b-0bc42f767e00"),
Name = "Administrator", Name = "Administrator",
NormalizedName = "ADMINISTRATOR" NormalizedName = "ADMINISTRATOR"
}); });

View File

@@ -281,7 +281,8 @@ namespace TechHelper.Server.Migrations
created_at = table.Column<DateTime>(type: "datetime(6)", nullable: false) created_at = table.Column<DateTime>(type: "datetime(6)", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
updated_at = table.Column<DateTime>(type: "datetime(6)", rowVersion: true, nullable: false), updated_at = table.Column<DateTime>(type: "datetime(6)", rowVersion: true, nullable: false),
deleted = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false) deleted = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
valid_question = table.Column<bool>(type: "tinyint(1)", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
@@ -330,17 +331,18 @@ namespace TechHelper.Server.Migrations
.Annotation("MySql:CharSet", "utf8mb4"), .Annotation("MySql:CharSet", "utf8mb4"),
descript = table.Column<string>(type: "longtext", maxLength: 65535, nullable: false) descript = table.Column<string>(type: "longtext", maxLength: 65535, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"), .Annotation("MySql:CharSet", "utf8mb4"),
total_points = table.Column<decimal>(type: "decimal(65,30)", nullable: true), total_points = table.Column<float>(type: "float", nullable: true),
number = table.Column<byte>(type: "tinyint unsigned", nullable: false), number = table.Column<byte>(type: "tinyint unsigned", nullable: false),
sub_group = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"), parent_group = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
deleted = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false) deleted = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false),
valid_question_group = table.Column<bool>(type: "tinyint(1)", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_assignment_group", x => x.id); table.PrimaryKey("PK_assignment_group", x => x.id);
table.ForeignKey( table.ForeignKey(
name: "FK_assignment_group_assignment_group_sub_group", name: "FK_assignment_group_assignment_group_parent_group",
column: x => x.sub_group, column: x => x.parent_group,
principalTable: "assignment_group", principalTable: "assignment_group",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.SetNull); onDelete: ReferentialAction.SetNull);
@@ -482,18 +484,18 @@ namespace TechHelper.Server.Migrations
{ {
id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"), id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
question_id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"), question_id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
group_id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
question_number = table.Column<byte>(type: "tinyint unsigned", nullable: false), question_number = table.Column<byte>(type: "tinyint unsigned", nullable: false),
created_at = table.Column<DateTime>(type: "datetime(6)", nullable: false), created_at = table.Column<DateTime>(type: "datetime(6)", nullable: false),
score = table.Column<float>(type: "float", nullable: true), score = table.Column<float>(type: "float", nullable: true),
detail_id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
deleted = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false) deleted = table.Column<bool>(type: "tinyint(1)", nullable: false, defaultValue: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_assignment_questions", x => x.id); table.PrimaryKey("PK_assignment_questions", x => x.id);
table.ForeignKey( table.ForeignKey(
name: "FK_assignment_questions_assignment_group_detail_id", name: "FK_assignment_questions_assignment_group_group_id",
column: x => x.detail_id, column: x => x.group_id,
principalTable: "assignment_group", principalTable: "assignment_group",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
@@ -554,9 +556,9 @@ namespace TechHelper.Server.Migrations
columns: new[] { "Id", "ConcurrencyStamp", "Name", "NormalizedName" }, columns: new[] { "Id", "ConcurrencyStamp", "Name", "NormalizedName" },
values: new object[,] values: new object[,]
{ {
{ new Guid("9de22e41-c096-4d5a-b55a-ce0122aa3ada"), null, "Teacher", "TEACHER" }, { new Guid("02a808ba-bd16-4f90-bf2b-0bc42f767e00"), null, "Administrator", "ADMINISTRATOR" },
{ new Guid("dee718d9-b731-485f-96bb-a59ce777870f"), null, "Administrator", "ADMINISTRATOR" }, { new Guid("9e526681-e57e-46b5-a01c-5731b27bfc4a"), null, "Student", "STUDENT" },
{ new Guid("ea0c88d8-1a52-4034-bb37-5a95043821eb"), null, "Student", "STUDENT" } { new Guid("dfdfb884-4063-4161-84e0-9c225f4e883c"), null, "Teacher", "TEACHER" }
}); });
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
@@ -612,14 +614,14 @@ namespace TechHelper.Server.Migrations
column: "assignment"); column: "assignment");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_assignment_group_sub_group", name: "IX_assignment_group_parent_group",
table: "assignment_group", table: "assignment_group",
column: "sub_group"); column: "parent_group");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_assignment_questions_detail_id", name: "IX_assignment_questions_group_id",
table: "assignment_questions", table: "assignment_questions",
column: "detail_id"); column: "group_id");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_assignment_questions_question_id", name: "IX_assignment_questions_question_id",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace TechHelper.Server.Migrations
{
/// <inheritdoc />
public partial class assignmentnot_required : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "AspNetRoles",
keyColumn: "Id",
keyValue: new Guid("02a808ba-bd16-4f90-bf2b-0bc42f767e00"));
migrationBuilder.DeleteData(
table: "AspNetRoles",
keyColumn: "Id",
keyValue: new Guid("9e526681-e57e-46b5-a01c-5731b27bfc4a"));
migrationBuilder.DeleteData(
table: "AspNetRoles",
keyColumn: "Id",
keyValue: new Guid("dfdfb884-4063-4161-84e0-9c225f4e883c"));
migrationBuilder.AlterColumn<Guid>(
name: "assignment",
table: "assignment_group",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)")
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.InsertData(
table: "AspNetRoles",
columns: new[] { "Id", "ConcurrencyStamp", "Name", "NormalizedName" },
values: new object[,]
{
{ new Guid("b2e087e6-ea32-46c4-aeb3-09b936cd0cf4"), null, "Teacher", "TEACHER" },
{ new Guid("ba33e047-8354-4f2c-b8b1-1f46441c28fc"), null, "Administrator", "ADMINISTRATOR" },
{ new Guid("d4b41bc3-612e-49dd-aeda-6a98ea0e4e68"), null, "Student", "STUDENT" }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "AspNetRoles",
keyColumn: "Id",
keyValue: new Guid("b2e087e6-ea32-46c4-aeb3-09b936cd0cf4"));
migrationBuilder.DeleteData(
table: "AspNetRoles",
keyColumn: "Id",
keyValue: new Guid("ba33e047-8354-4f2c-b8b1-1f46441c28fc"));
migrationBuilder.DeleteData(
table: "AspNetRoles",
keyColumn: "Id",
keyValue: new Guid("d4b41bc3-612e-49dd-aeda-6a98ea0e4e68"));
migrationBuilder.AlterColumn<Guid>(
name: "assignment",
table: "assignment_group",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci",
oldClrType: typeof(Guid),
oldType: "char(36)",
oldNullable: true)
.OldAnnotation("Relational:Collation", "ascii_general_ci");
migrationBuilder.InsertData(
table: "AspNetRoles",
columns: new[] { "Id", "ConcurrencyStamp", "Name", "NormalizedName" },
values: new object[,]
{
{ new Guid("02a808ba-bd16-4f90-bf2b-0bc42f767e00"), null, "Administrator", "ADMINISTRATOR" },
{ new Guid("9e526681-e57e-46b5-a01c-5731b27bfc4a"), null, "Student", "STUDENT" },
{ new Guid("dfdfb884-4063-4161-84e0-9c225f4e883c"), null, "Teacher", "TEACHER" }
});
}
}
}

View File

@@ -155,7 +155,7 @@ namespace TechHelper.Server.Migrations
.HasColumnType("char(36)") .HasColumnType("char(36)")
.HasColumnName("id"); .HasColumnName("id");
b.Property<Guid>("AssignmentId") b.Property<Guid?>("AssignmentId")
.HasColumnType("char(36)") .HasColumnType("char(36)")
.HasColumnName("assignment"); .HasColumnName("assignment");
@@ -177,7 +177,7 @@ namespace TechHelper.Server.Migrations
b.Property<Guid?>("ParentGroup") b.Property<Guid?>("ParentGroup")
.HasColumnType("char(36)") .HasColumnType("char(36)")
.HasColumnName("sub_group"); .HasColumnName("parent_group");
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
@@ -185,10 +185,14 @@ namespace TechHelper.Server.Migrations
.HasColumnType("longtext") .HasColumnType("longtext")
.HasColumnName("title"); .HasColumnName("title");
b.Property<decimal?>("TotalPoints") b.Property<float?>("TotalPoints")
.HasColumnType("decimal(65,30)") .HasColumnType("float")
.HasColumnName("total_points"); .HasColumnName("total_points");
b.Property<bool>("ValidQuestionGroup")
.HasColumnType("tinyint(1)")
.HasColumnName("valid_question_group");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AssignmentId"); b.HasIndex("AssignmentId");
@@ -207,7 +211,7 @@ namespace TechHelper.Server.Migrations
b.Property<Guid>("AssignmentGroupId") b.Property<Guid>("AssignmentGroupId")
.HasColumnType("char(36)") .HasColumnType("char(36)")
.HasColumnName("detail_id"); .HasColumnName("group_id");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)") .HasColumnType("datetime(6)")
@@ -401,6 +405,10 @@ namespace TechHelper.Server.Migrations
.HasColumnType("datetime(6)") .HasColumnType("datetime(6)")
.HasColumnName("updated_at"); .HasColumnName("updated_at");
b.Property<bool>("ValidQuestion")
.HasColumnType("tinyint(1)")
.HasColumnName("valid_question");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CreatedBy"); b.HasIndex("CreatedBy");
@@ -650,19 +658,19 @@ namespace TechHelper.Server.Migrations
b.HasData( b.HasData(
new new
{ {
Id = new Guid("ea0c88d8-1a52-4034-bb37-5a95043821eb"), Id = new Guid("d4b41bc3-612e-49dd-aeda-6a98ea0e4e68"),
Name = "Student", Name = "Student",
NormalizedName = "STUDENT" NormalizedName = "STUDENT"
}, },
new new
{ {
Id = new Guid("9de22e41-c096-4d5a-b55a-ce0122aa3ada"), Id = new Guid("b2e087e6-ea32-46c4-aeb3-09b936cd0cf4"),
Name = "Teacher", Name = "Teacher",
NormalizedName = "TEACHER" NormalizedName = "TEACHER"
}, },
new new
{ {
Id = new Guid("dee718d9-b731-485f-96bb-a59ce777870f"), Id = new Guid("ba33e047-8354-4f2c-b8b1-1f46441c28fc"),
Name = "Administrator", Name = "Administrator",
NormalizedName = "ADMINISTRATOR" NormalizedName = "ADMINISTRATOR"
}); });
@@ -821,8 +829,7 @@ namespace TechHelper.Server.Migrations
b.HasOne("Entities.Contracts.Assignment", "Assignment") b.HasOne("Entities.Contracts.Assignment", "Assignment")
.WithMany("AssignmentGroups") .WithMany("AssignmentGroups")
.HasForeignKey("AssignmentId") .HasForeignKey("AssignmentId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade);
.IsRequired();
b.HasOne("Entities.Contracts.AssignmentGroup", "ParentAssignmentGroup") b.HasOne("Entities.Contracts.AssignmentGroup", "ParentAssignmentGroup")
.WithMany("ChildAssignmentGroups") .WithMany("ChildAssignmentGroups")

View File

@@ -10,6 +10,7 @@ using Microsoft.IdentityModel.Tokens;
using System.Text; using System.Text;
using TechHelper.Features; using TechHelper.Features;
using TechHelper.Services; using TechHelper.Services;
using TechHelper.Server.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -83,6 +84,7 @@ builder.Services.AddScoped<IAuthenticationService, AuthenticationService>();
builder.Services.AddScoped<IEmailSender, QEmailSender>(); builder.Services.AddScoped<IEmailSender, QEmailSender>();
builder.Services.AddTransient<IUserRegistrationService, UserRegistrationService>(); builder.Services.AddTransient<IUserRegistrationService, UserRegistrationService>();
builder.Services.AddScoped<IClassService, ClassService>(); builder.Services.AddScoped<IClassService, ClassService>();
builder.Services.AddScoped<IExamService, ExamService>();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();

View File

@@ -3,8 +3,10 @@ using Entities.Contracts;
using Entities.DTO; using Entities.DTO;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MySqlConnector;
using SharedDATA.Api; using SharedDATA.Api;
using TechHelper.Services; using TechHelper.Services;
using static TechHelper.Context.AutoMapperProFile;
namespace TechHelper.Server.Services namespace TechHelper.Server.Services
{ {
@@ -81,6 +83,74 @@ namespace TechHelper.Server.Services
} }
public async Task<IEnumerable<AssignmentGroup>> LoadFullGroupTree(Guid rootGroupId)
{
var query = @"
WITH RECURSIVE GroupTree AS (
SELECT
ag.*,
CAST(ag.`number` AS CHAR(255)) AS path
FROM assignment_group ag
WHERE ag.id = @rootId
UNION ALL
SELECT
c.*,
CONCAT(ct.path, '.', c.`number`)
FROM assignment_group c
INNER JOIN GroupTree ct ON c.parent_group = ct.id
)
SELECT * FROM GroupTree ORDER BY path;
";
// 执行查询
var groups = await _unitOfWork.GetRepository<AssignmentGroup>()
.FromSql(query, new MySqlParameter("rootId", rootGroupId))
.ToListAsync();
// 内存中构建树结构
var groupDict = groups.ToDictionary(g => g.Id);
var root = groupDict[rootGroupId];
foreach (var group in groups)
{
if (group.ParentGroup != null && groupDict.TryGetValue(group.ParentGroup.Value, out var parent))
{
parent.ChildAssignmentGroups ??= new List<AssignmentGroup>();
parent.ChildAssignmentGroups.Add(group);
}
}
return new List<AssignmentGroup> { root };
}
public async Task LoadRecursiveAssignmentGroups(IEnumerable<AssignmentGroup> groups)
{
foreach (var group in groups.ToList())
{
var loadedGroup = await _unitOfWork.GetRepository<AssignmentGroup>()
.GetFirstOrDefaultAsync(
predicate: ag => ag.Id == group.Id,
include: source => source
.Include(ag => ag.AssignmentQuestions.Where(aq => !aq.IsDeleted))
.ThenInclude(aq => aq.Question)
.Include(ag => ag.ChildAssignmentGroups)
);
if (loadedGroup == null) continue;
group.ChildAssignmentGroups = loadedGroup.ChildAssignmentGroups;
group.AssignmentQuestions = loadedGroup.AssignmentQuestions;
if (group.ChildAssignmentGroups is { Count: > 0 })
{
await LoadRecursiveAssignmentGroups(group.ChildAssignmentGroups);
}
}
}
public async Task<ApiResponse> GetExamByIdAsync(Guid assignmentId) public async Task<ApiResponse> GetExamByIdAsync(Guid assignmentId)
{ {
try try
@@ -93,15 +163,17 @@ namespace TechHelper.Server.Services
return ApiResponse.Error($"找不到 ID 为 {assignmentId} 的试卷。"); return ApiResponse.Error($"找不到 ID 为 {assignmentId} 的试卷。");
} }
// 获取所有相关题组和题目,并过滤掉已删除的 // 获取所有相关题组和题目,并过滤掉已删除的
var allGroups = await _unitOfWork.GetRepository<AssignmentGroup>().GetAllAsync( var allGroups = await _unitOfWork.GetRepository<AssignmentGroup>().GetFirstOrDefaultAsync(
predicate: ag => ag.AssignmentId == assignmentId && !ag.IsDeleted, predicate: ag => ag.AssignmentId == assignmentId && !ag.IsDeleted,
include: source => source include: source => source
.Include(ag => ag.AssignmentQuestions.Where(aq => !aq.IsDeleted)) .Include(ag => ag.ChildAssignmentGroups)
.ThenInclude(aq => aq.Question)
); );
await LoadRecursiveAssignmentGroups(allGroups.ChildAssignmentGroups);
if (allGroups == null || !allGroups.Any()) if (allGroups == null || !allGroups.ChildAssignmentGroups.Any())
{ {
// 试卷存在但没有内容,返回一个空的 ExamDto // 试卷存在但没有内容,返回一个空的 ExamDto
return ApiResponse.Success("试卷没有内容。", new ExamDto return ApiResponse.Success("试卷没有内容。", new ExamDto
@@ -113,10 +185,16 @@ namespace TechHelper.Server.Services
}); });
} }
var rootGroups = allGroups var rootGroups = allGroups.ChildAssignmentGroups.ToList();
.Where(ag => ag.ParentGroup == null)
.OrderBy(ag => ag.Number) var rootqg = new QuestionGroupDto();
.ToList();
foreach (var ag in rootGroups.OrderBy(g => g.Number))
{
var agDto = MapAssignmentGroupToDto(ag);
rootqg.SubQuestionGroups.Add(agDto);
}
// 递归映射到 ExamDto // 递归映射到 ExamDto
var examDto = new ExamDto var examDto = new ExamDto
@@ -125,7 +203,7 @@ namespace TechHelper.Server.Services
AssignmentTitle = assignment.Title, AssignmentTitle = assignment.Title,
Description = assignment.Description, Description = assignment.Description,
SubjectArea = assignment.Submissions.ToString(), SubjectArea = assignment.Submissions.ToString(),
QuestionGroups = MapAssignmentGroupsToDto(rootGroups, allGroups) QuestionGroups = rootqg
}; };
return ApiResponse.Success("试卷信息已成功获取。", examDto); return ApiResponse.Success("试卷信息已成功获取。", examDto);
@@ -137,9 +215,45 @@ namespace TechHelper.Server.Services
} }
private List<QuestionGroupDto> MapAssignmentGroupsToDto( public QuestionGroupDto MapAssignmentGroupToDto(AssignmentGroup ag)
List<AssignmentGroup> currentLevelGroups, {
IEnumerable<AssignmentGroup> allFetchedGroups) // 创建当前节点的DTO
var dto = new QuestionGroupDto
{
Title = ag.Title,
Score = (int)(ag.TotalPoints ?? 0),
Descript = ag.Descript,
SubQuestions = ag.AssignmentQuestions?
.OrderBy(aq => aq.QuestionNumber)
.Select(aq => new SubQuestionDto
{
Index = aq.QuestionNumber,
Stem = aq.Question?.QuestionText,
Score = aq.Score ?? 0,
SampleAnswer = aq.Question?.CorrectAnswer,
QuestionType = aq.Question?.QuestionType.ToString(),
DifficultyLevel = aq.Question?.DifficultyLevel.ToString(),
Options = new List<OptionDto>() // 根据需要初始化
}).ToList() ?? new List<SubQuestionDto>(),
SubQuestionGroups = new List<QuestionGroupDto>() // 初始化子集合
};
// 递归处理子组
if (ag.ChildAssignmentGroups != null && ag.ChildAssignmentGroups.Count > 0)
{
foreach (var child in ag.ChildAssignmentGroups.OrderBy(c => c.Number))
{
var childDto = MapAssignmentGroupToDto(child); // 递归获取子DTO
dto.SubQuestionGroups.Add(childDto); // 添加到当前节点的子集合
}
}
return dto;
}
private List<QuestionGroupDto> MapAssignmentGroupsToDto2(
List<AssignmentGroup> currentLevelGroups,
IEnumerable<AssignmentGroup> allFetchedGroups)
{ {
var dtos = new List<QuestionGroupDto>(); var dtos = new List<QuestionGroupDto>();
@@ -147,25 +261,25 @@ namespace TechHelper.Server.Services
{ {
var groupDto = new QuestionGroupDto var groupDto = new QuestionGroupDto
{ {
Title = group.Title, Title = group.Title,
Score = (int)(group.TotalPoints ?? 0), Score = (int)(group.TotalPoints ?? 0),
QuestionReference = group.Descript, Descript = group.Descript,
SubQuestions = group.AssignmentQuestions SubQuestions = group.AssignmentQuestions
.OrderBy(aq => aq.QuestionNumber) .OrderBy(aq => aq.QuestionNumber)
.Select(aq => new SubQuestionDto .Select(aq => new SubQuestionDto
{ {
Index = aq.QuestionNumber, Index = aq.QuestionNumber,
Stem = aq.Question.QuestionText, Stem = aq.Question.QuestionText,
Score = aq.Score?? 0, // 使用 AssignmentQuestion 上的 Score Score = aq.Score ?? 0,
SampleAnswer = aq.Question.CorrectAnswer, SampleAnswer = aq.Question.CorrectAnswer,
QuestionType = aq.Question.QuestionType.ToString(), QuestionType = aq.Question.QuestionType.ToString(),
DifficultyLevel = aq.Question.DifficultyLevel.ToString(), DifficultyLevel = aq.Question.DifficultyLevel.ToString(),
Options = new List<OptionDto>() // 这里需要您根据实际存储方式填充 Option Options = new List<OptionDto>()
}).ToList(), }).ToList(),
// 递归映射子题组 // 递归映射子题组
SubQuestionGroups = MapAssignmentGroupsToDto( SubQuestionGroups = MapAssignmentGroupsToDto2(
allFetchedGroups.Where(ag => ag.ParentGroup == group.Id && !ag.IsDeleted).ToList(), // 从所有已获取的组中筛选子组 allFetchedGroups.Where(ag => ag.ParentGroup == group.Id && !ag.IsDeleted).ToList(),
allFetchedGroups) allFetchedGroups)
}; };
dtos.Add(groupDto); dtos.Add(groupDto);
@@ -176,7 +290,7 @@ namespace TechHelper.Server.Services
public async Task<TechHelper.Services.ApiResponse> SaveParsedExam(ExamDto examData) public async Task<TechHelper.Services.ApiResponse> SaveParsedExam(ExamDto examData)
{ {
// 获取当前登录用户 // 获取当前登录用户
var currentUser = await _userManager.GetUserAsync(null); var currentUser = await _userManager.FindByEmailAsync(examData.CreaterEmail);
if (currentUser == null) if (currentUser == null)
{ {
return ApiResponse.Error("未找到当前登录用户,无法保存试题。"); return ApiResponse.Error("未找到当前登录用户,无法保存试题。");
@@ -204,16 +318,13 @@ namespace TechHelper.Server.Services
// 从 ExamDto.QuestionGroups 获取根题组。 // 从 ExamDto.QuestionGroups 获取根题组。
// 确保只有一个根题组,因为您的模型是“试卷只有一个根节点”。 // 确保只有一个根题组,因为您的模型是“试卷只有一个根节点”。
if (examData.QuestionGroups == null || examData.QuestionGroups.Count != 1) if (examData.QuestionGroups == null)
{ {
throw new ArgumentException("试卷必须包含且只能包含一个根题组。"); throw new ArgumentException("试卷必须包含且只能包含一个根题组。");
} }
// 递归处理根题组及其所有子题组和题目
// 传入的 assignmentId 仅用于设置根题组的 AssignmentId 字段
// 对于子题组ProcessAndSaveAssignmentGroupsRecursive 会将 AssignmentId 设置为 null
await ProcessAndSaveAssignmentGroupsRecursive( await ProcessAndSaveAssignmentGroupsRecursive(
examData.QuestionGroups.Single(), examData.QuestionGroups,
examData.SubjectArea.ToString(), examData.SubjectArea.ToString(),
assignmentId, assignmentId,
null, // 根题组没有父级 null, // 根题组没有父级
@@ -246,14 +357,12 @@ namespace TechHelper.Server.Services
{ {
Id = Guid.NewGuid(), // 后端生成 GUID Id = Guid.NewGuid(), // 后端生成 GUID
Title = qgDto.Title, Title = qgDto.Title,
Descript = qgDto.QuestionReference, Descript = qgDto.Descript,
TotalPoints = qgDto.Score, TotalPoints = qgDto.Score,
Number = (byte)qgDto.Index, // 使用 DTO 的 Index 作为 Number Number = (byte)qgDto.Index,
ParentGroup = parentAssignmentGroupId, // 设置父级题组 GUID ValidQuestionGroup = qgDto.ValidQuestionGroup,
ParentGroup = parentAssignmentGroupId,
// 关键修正:只有当 parentAssignmentGroupId 为 null 时,才设置 AssignmentId AssignmentId = parentAssignmentGroupId == null ? assignmentId : (Guid?)null,
// 这意味着当前题组是顶级题组
AssignmentId = parentAssignmentGroupId == null ? assignmentId : Guid.Empty,
IsDeleted = false IsDeleted = false
}; };
await _unitOfWork.GetRepository<AssignmentGroup>().InsertAsync(newAssignmentGroup); await _unitOfWork.GetRepository<AssignmentGroup>().InsertAsync(newAssignmentGroup);
@@ -268,10 +377,7 @@ namespace TechHelper.Server.Services
newQuestion.CreatedAt = DateTime.UtcNow; newQuestion.CreatedAt = DateTime.UtcNow;
newQuestion.UpdatedAt = DateTime.UtcNow; newQuestion.UpdatedAt = DateTime.UtcNow;
newQuestion.IsDeleted = false; newQuestion.IsDeleted = false;
newQuestion.SubjectArea = (SubjectAreaEnum)Enum.Parse(typeof(SubjectAreaEnum), subjectarea, true); newQuestion.SubjectArea = EnumMappingHelpers.ParseEnumSafe(subjectarea, SubjectAreaEnum.Unknown);
// 处理 Options如果 Options 是 JSON 字符串或需要其他存储方式,在这里处理
// 例如newQuestion.QuestionText += (JsonConvert.SerializeObject(sqDto.Options));
await _unitOfWork.GetRepository<Question>().InsertAsync(newQuestion); await _unitOfWork.GetRepository<Question>().InsertAsync(newQuestion);
@@ -279,9 +385,9 @@ namespace TechHelper.Server.Services
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
QuestionId = newQuestion.Id, QuestionId = newQuestion.Id,
QuestionNumber = (byte)questionNumber, // 使用递增的 questionNumber QuestionNumber = (byte)questionNumber,
AssignmentGroupId = newAssignmentGroup.Id, // 关联到当前题组 AssignmentGroupId = newAssignmentGroup.Id,
Score = sqDto.Score, // 从 DTO 获取单个子题分数 Score = sqDto.Score,
IsDeleted = false, IsDeleted = false,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
@@ -302,5 +408,26 @@ namespace TechHelper.Server.Services
createdById); createdById);
} }
} }
public async Task<ApiResponse> GetAllExamPreview(Guid user)
{
try
{
var assignments = await _unitOfWork.GetRepository<Assignment>().GetAllAsync(
predicate: a => a.CreatedBy == user && !a.IsDeleted);
if (assignments.Any())
{
var exam = _mapper.Map<IEnumerable<ExamDto>>(assignments);
return ApiResponse.Success(result: exam);
}
return ApiResponse.Error("你还没有创建任何试卷");
}
catch (Exception ex)
{
return ApiResponse.Error($"查询出了一点问题 , 详细信息为: {ex.Message}, 请稍后再试");
}
}
} }
} }

View File

@@ -5,6 +5,6 @@ namespace TechHelper.Server.Services
{ {
public interface IExamService : IBaseService<ExamDto, Guid> public interface IExamService : IBaseService<ExamDto, Guid>
{ {
Task<ApiResponse> GetAllExamPreview(Guid user);
} }
} }

View File

@@ -6,7 +6,7 @@
} }
}, },
"ConnectionStrings": { "ConnectionStrings": {
"XSDB": "Server=mysql.eazygame.cn;Port=13002;Database=tech_helper;User=root;Password=wx1998WX" "XSDB": "Server=mysql.eazygame.cn;Port=13002;Database=test;User=root;Password=wx1998WX"
}, },
"JWTSettings": { "JWTSettings": {
"securityKey": "MxcxQHVYVDQ0U3lqWkIwdjZlSGx4eFp6YnFpUGxodmc5Y3hPZk5vWm9MZEg2Y0I=", "securityKey": "MxcxQHVYVDQ0U3lqWkIwdjZlSGx4eFp6YnFpUGxodmc5Y3hPZk5vWm9MZEg2Y0I=",