add: 添加实验列表界面,实验增删完全依赖数据库实现
This commit is contained in:
		@@ -168,6 +168,17 @@ try
 | 
			
		||||
            FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "log")),
 | 
			
		||||
            RequestPath = "/log"
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Exam Files (实验静态资源)
 | 
			
		||||
        if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), "exam")))
 | 
			
		||||
        {
 | 
			
		||||
            app.UseStaticFiles(new StaticFileOptions
 | 
			
		||||
            {
 | 
			
		||||
                FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "exam")),
 | 
			
		||||
                RequestPath = "/exam"
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        app.MapFallbackToFile("index.html");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -194,19 +205,6 @@ try
 | 
			
		||||
    // Setup Program
 | 
			
		||||
    MsgBus.Init();
 | 
			
		||||
 | 
			
		||||
    // 扫描并更新实验数据库
 | 
			
		||||
    try
 | 
			
		||||
    {
 | 
			
		||||
        using var db = new Database.AppDataConnection();
 | 
			
		||||
        var examFolderPath = Path.Combine(Directory.GetCurrentDirectory(), "exam");
 | 
			
		||||
        var updateCount = db.ScanAndUpdateExams(examFolderPath);
 | 
			
		||||
        logger.Info($"实验数据库扫描完成,更新了 {updateCount} 个实验");
 | 
			
		||||
    }
 | 
			
		||||
    catch (Exception ex)
 | 
			
		||||
    {
 | 
			
		||||
        logger.Error($"扫描实验文件夹时出错: {ex.Message}");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Generate API Client
 | 
			
		||||
    app.MapGet("GetAPIClientCode", async (HttpContext context) =>
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,37 +0,0 @@
 | 
			
		||||
# 实验001:基础逻辑门电路
 | 
			
		||||
 | 
			
		||||
## 实验目的
 | 
			
		||||
 | 
			
		||||
本实验旨在帮助学生理解基础逻辑门的工作原理,包括与门、或门、非门等基本逻辑运算。
 | 
			
		||||
 | 
			
		||||
## 实验内容
 | 
			
		||||
 | 
			
		||||
### 1. 与门(AND Gate)
 | 
			
		||||
与门是一个基本的逻辑门,当所有输入都为高电平(1)时,输出才为高电平(1)。
 | 
			
		||||
 | 
			
		||||
### 2. 或门(OR Gate)
 | 
			
		||||
或门是另一个基本的逻辑门,当任意一个输入为高电平(1)时,输出就为高电平(1)。
 | 
			
		||||
 | 
			
		||||
### 3. 非门(NOT Gate)
 | 
			
		||||
非门是一个反相器,输入为高电平时输出为低电平,反之亦然。
 | 
			
		||||
 | 
			
		||||
## 实验步骤
 | 
			
		||||
 | 
			
		||||
1. 打开 FPGA 开发环境
 | 
			
		||||
2. 创建新的项目文件
 | 
			
		||||
3. 编写 Verilog 代码实现各种逻辑门
 | 
			
		||||
4. 进行仿真验证
 | 
			
		||||
5. 下载到 FPGA 板进行硬件验证
 | 
			
		||||
 | 
			
		||||
## 预期结果
 | 
			
		||||
 | 
			
		||||
通过本实验,学生应该能够:
 | 
			
		||||
- 理解基本逻辑门的真值表
 | 
			
		||||
- 掌握 Verilog 代码的基本语法
 | 
			
		||||
- 学会使用 FPGA 开发工具进行仿真
 | 
			
		||||
 | 
			
		||||
## 注意事项
 | 
			
		||||
 | 
			
		||||
- 确保输入信号的电平正确
 | 
			
		||||
- 注意时序的约束
 | 
			
		||||
- 验证结果时要仔细对比真值表
 | 
			
		||||
@@ -1,35 +0,0 @@
 | 
			
		||||
# 实验002:组合逻辑电路设计
 | 
			
		||||
 | 
			
		||||
## 实验目的
 | 
			
		||||
 | 
			
		||||
本实验旨在让学生学习如何设计和实现复杂的组合逻辑电路,掌握多个逻辑门的组合使用。
 | 
			
		||||
 | 
			
		||||
## 实验内容
 | 
			
		||||
 | 
			
		||||
### 1. 半加器设计
 | 
			
		||||
设计一个半加器电路,实现两个一位二进制数的加法运算。
 | 
			
		||||
 | 
			
		||||
### 2. 全加器设计
 | 
			
		||||
在半加器的基础上,设计全加器电路,考虑进位输入。
 | 
			
		||||
 | 
			
		||||
### 3. 编码器和译码器
 | 
			
		||||
实现简单的编码器和译码器电路。
 | 
			
		||||
 | 
			
		||||
## 实验要求
 | 
			
		||||
 | 
			
		||||
1. 使用 Verilog HDL 编写代码
 | 
			
		||||
2. 绘制逻辑电路图
 | 
			
		||||
3. 编写测试用例验证功能
 | 
			
		||||
4. 分析电路的延时特性
 | 
			
		||||
 | 
			
		||||
## 评估标准
 | 
			
		||||
 | 
			
		||||
- 电路功能正确性 (40%)
 | 
			
		||||
- 代码质量和规范性 (30%)  
 | 
			
		||||
- 测试覆盖率 (20%)
 | 
			
		||||
- 实验报告 (10%)
 | 
			
		||||
 | 
			
		||||
## 参考资料
 | 
			
		||||
 | 
			
		||||
- 数字逻辑设计教材第3-4章
 | 
			
		||||
- Verilog HDL 语法参考手册
 | 
			
		||||
@@ -25,9 +25,14 @@ public class ExamController : ControllerBase
 | 
			
		||||
        public required string ID { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验文档内容(Markdown格式)
 | 
			
		||||
        /// 实验名称
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public required string DocContent { get; set; }
 | 
			
		||||
        public required string Name { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验描述
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public required string Description { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验创建时间
 | 
			
		||||
@@ -38,6 +43,21 @@ public class ExamController : ControllerBase
 | 
			
		||||
        /// 实验最后更新时间
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public DateTime UpdatedTime { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验标签
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public string[] Tags { get; set; } = Array.Empty<string>();
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验难度(1-5)
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public int Difficulty { get; set; } = 1;
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 普通用户是否可见
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public bool IsVisibleToUsers { get; set; } = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
@@ -50,6 +70,11 @@ public class ExamController : ControllerBase
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public required string ID { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验名称
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public required string Name { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验创建时间
 | 
			
		||||
        /// </summary>
 | 
			
		||||
@@ -61,25 +86,71 @@ public class ExamController : ControllerBase
 | 
			
		||||
        public DateTime UpdatedTime { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验标题(从文档内容中提取)
 | 
			
		||||
        /// 实验标签
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public string Title { get; set; } = "";
 | 
			
		||||
        public string[] Tags { get; set; } = Array.Empty<string>();
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验难度(1-5)
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public int Difficulty { get; set; } = 1;
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 普通用户是否可见
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public bool IsVisibleToUsers { get; set; } = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 扫描结果类
 | 
			
		||||
    /// 资源信息类
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public class ScanResult
 | 
			
		||||
    public class ResourceInfo
 | 
			
		||||
    {
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 结果消息
 | 
			
		||||
        /// 资源ID
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public required string Message { get; set; }
 | 
			
		||||
        public int ID { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 更新的实验数量
 | 
			
		||||
        /// 资源名称
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public int UpdateCount { get; set; }
 | 
			
		||||
        public required string Name { get; set; }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 创建实验请求类
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public class CreateExamRequest
 | 
			
		||||
    {
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验ID
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public required string ID { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验名称
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public required string Name { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验描述
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public required string Description { get; set; }
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验标签
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public string[] Tags { get; set; } = Array.Empty<string>();
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 实验难度(1-5)
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public int Difficulty { get; set; } = 1;
 | 
			
		||||
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 普通用户是否可见
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public bool IsVisibleToUsers { get; set; } = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
@@ -102,9 +173,12 @@ public class ExamController : ControllerBase
 | 
			
		||||
            var examSummaries = exams.Select(exam => new ExamSummary
 | 
			
		||||
            {
 | 
			
		||||
                ID = exam.ID,
 | 
			
		||||
                Name = exam.Name,
 | 
			
		||||
                CreatedTime = exam.CreatedTime,
 | 
			
		||||
                UpdatedTime = exam.UpdatedTime,
 | 
			
		||||
                Title = ExtractTitleFromMarkdown(exam.DocContent)
 | 
			
		||||
                Tags = exam.GetTagsList(),
 | 
			
		||||
                Difficulty = exam.Difficulty,
 | 
			
		||||
                IsVisibleToUsers = exam.IsVisibleToUsers
 | 
			
		||||
            }).ToArray();
 | 
			
		||||
 | 
			
		||||
            logger.Info($"成功获取实验列表,共 {examSummaries.Length} 个实验");
 | 
			
		||||
@@ -156,9 +230,13 @@ public class ExamController : ControllerBase
 | 
			
		||||
            var examInfo = new ExamInfo
 | 
			
		||||
            {
 | 
			
		||||
                ID = exam.ID,
 | 
			
		||||
                DocContent = exam.DocContent,
 | 
			
		||||
                Name = exam.Name,
 | 
			
		||||
                Description = exam.Description,
 | 
			
		||||
                CreatedTime = exam.CreatedTime,
 | 
			
		||||
                UpdatedTime = exam.UpdatedTime
 | 
			
		||||
                UpdatedTime = exam.UpdatedTime,
 | 
			
		||||
                Tags = exam.GetTagsList(),
 | 
			
		||||
                Difficulty = exam.Difficulty,
 | 
			
		||||
                IsVisibleToUsers = exam.IsVisibleToUsers
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            logger.Info($"成功获取实验信息: {examId}");
 | 
			
		||||
@@ -172,60 +250,205 @@ public class ExamController : ControllerBase
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 重新扫描实验文件夹并更新数据库
 | 
			
		||||
    /// 创建新实验
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <returns>更新结果</returns>
 | 
			
		||||
    /// <param name="request">创建实验请求</param>
 | 
			
		||||
    /// <returns>创建结果</returns>
 | 
			
		||||
    [Authorize("Admin")]
 | 
			
		||||
    [HttpPost("scan")]
 | 
			
		||||
    [HttpPost]
 | 
			
		||||
    [EnableCors("Users")]
 | 
			
		||||
    [ProducesResponseType(typeof(ScanResult), StatusCodes.Status200OK)]
 | 
			
		||||
    [ProducesResponseType(typeof(ExamInfo), StatusCodes.Status201Created)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status403Forbidden)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status409Conflict)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status500InternalServerError)]
 | 
			
		||||
    public IActionResult ScanExams()
 | 
			
		||||
    public IActionResult CreateExam([FromBody] CreateExamRequest request)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(request.ID) || string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Description))
 | 
			
		||||
            return BadRequest("实验ID、名称和描述不能为空");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            using var db = new Database.AppDataConnection();
 | 
			
		||||
            var examFolderPath = Path.Combine(Directory.GetCurrentDirectory(), "exam");
 | 
			
		||||
            var updateCount = db.ScanAndUpdateExams(examFolderPath);
 | 
			
		||||
            var result = db.CreateExam(request.ID, request.Name, request.Description, request.Tags, request.Difficulty, request.IsVisibleToUsers);
 | 
			
		||||
 | 
			
		||||
            var result = new ScanResult
 | 
			
		||||
            if (!result.IsSuccessful)
 | 
			
		||||
            {
 | 
			
		||||
                Message = $"扫描完成,更新了 {updateCount} 个实验",
 | 
			
		||||
                UpdateCount = updateCount
 | 
			
		||||
                if (result.Error.Message.Contains("已存在"))
 | 
			
		||||
                    return Conflict(result.Error.Message);
 | 
			
		||||
                
 | 
			
		||||
                logger.Error($"创建实验时出错: {result.Error.Message}");
 | 
			
		||||
                return StatusCode(StatusCodes.Status500InternalServerError, $"创建实验失败: {result.Error.Message}");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var exam = result.Value;
 | 
			
		||||
            var examInfo = new ExamInfo
 | 
			
		||||
            {
 | 
			
		||||
                ID = exam.ID,
 | 
			
		||||
                Name = exam.Name,
 | 
			
		||||
                Description = exam.Description,
 | 
			
		||||
                CreatedTime = exam.CreatedTime,
 | 
			
		||||
                UpdatedTime = exam.UpdatedTime,
 | 
			
		||||
                Tags = exam.GetTagsList(),
 | 
			
		||||
                Difficulty = exam.Difficulty,
 | 
			
		||||
                IsVisibleToUsers = exam.IsVisibleToUsers
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            logger.Info($"手动扫描实验完成,更新了 {updateCount} 个实验");
 | 
			
		||||
            return Ok(result);
 | 
			
		||||
            logger.Info($"成功创建实验: {request.ID}");
 | 
			
		||||
            return CreatedAtAction(nameof(GetExam), new { examId = request.ID }, examInfo);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error($"扫描实验时出错: {ex.Message}");
 | 
			
		||||
            return StatusCode(StatusCodes.Status500InternalServerError, $"扫描实验失败: {ex.Message}");
 | 
			
		||||
            logger.Error($"创建实验 {request.ID} 时出错: {ex.Message}");
 | 
			
		||||
            return StatusCode(StatusCodes.Status500InternalServerError, $"创建实验失败: {ex.Message}");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 从 Markdown 内容中提取标题
 | 
			
		||||
    /// 添加实验资源
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="markdownContent">Markdown 内容</param>
 | 
			
		||||
    /// <returns>提取的标题</returns>
 | 
			
		||||
    private static string ExtractTitleFromMarkdown(string markdownContent)
 | 
			
		||||
    /// <param name="examId">实验ID</param>
 | 
			
		||||
    /// <param name="resourceType">资源类型</param>
 | 
			
		||||
    /// <param name="file">资源文件</param>
 | 
			
		||||
    /// <returns>添加结果</returns>
 | 
			
		||||
    [Authorize("Admin")]
 | 
			
		||||
    [HttpPost("{examId}/resources/{resourceType}")]
 | 
			
		||||
    [EnableCors("Users")]
 | 
			
		||||
    [ProducesResponseType(typeof(ResourceInfo), StatusCodes.Status201Created)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status403Forbidden)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status404NotFound)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status409Conflict)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status500InternalServerError)]
 | 
			
		||||
    public async Task<IActionResult> AddExamResource(string examId, string resourceType, IFormFile file)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrEmpty(markdownContent))
 | 
			
		||||
            return "";
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(examId) || string.IsNullOrWhiteSpace(resourceType) || file == null)
 | 
			
		||||
            return BadRequest("实验ID、资源类型和文件不能为空");
 | 
			
		||||
 | 
			
		||||
        var lines = markdownContent.Split('\n');
 | 
			
		||||
        foreach (var line in lines)
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var trimmedLine = line.Trim();
 | 
			
		||||
            if (trimmedLine.StartsWith("# "))
 | 
			
		||||
            {
 | 
			
		||||
                return trimmedLine.Substring(2).Trim();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
            using var db = new Database.AppDataConnection();
 | 
			
		||||
            
 | 
			
		||||
            // 读取文件数据
 | 
			
		||||
            using var memoryStream = new MemoryStream();
 | 
			
		||||
            await file.CopyToAsync(memoryStream);
 | 
			
		||||
            var fileData = memoryStream.ToArray();
 | 
			
		||||
 | 
			
		||||
        return "";
 | 
			
		||||
            var result = db.AddExamResource(examId, resourceType, file.FileName, fileData);
 | 
			
		||||
 | 
			
		||||
            if (!result.IsSuccessful)
 | 
			
		||||
            {
 | 
			
		||||
                if (result.Error.Message.Contains("不存在"))
 | 
			
		||||
                    return NotFound(result.Error.Message);
 | 
			
		||||
                if (result.Error.Message.Contains("已存在"))
 | 
			
		||||
                    return Conflict(result.Error.Message);
 | 
			
		||||
                
 | 
			
		||||
                logger.Error($"添加实验资源时出错: {result.Error.Message}");
 | 
			
		||||
                return StatusCode(StatusCodes.Status500InternalServerError, $"添加实验资源失败: {result.Error.Message}");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var resource = result.Value;
 | 
			
		||||
            var resourceInfo = new ResourceInfo
 | 
			
		||||
            {
 | 
			
		||||
                ID = resource.ID,
 | 
			
		||||
                Name = resource.ResourceName
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            logger.Info($"成功添加实验资源: {examId}/{resourceType}/{file.FileName}");
 | 
			
		||||
            return CreatedAtAction(nameof(GetExamResourceById), new { resourceId = resource.ID }, resourceInfo);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error($"添加实验资源 {examId}/{resourceType}/{file.FileName} 时出错: {ex.Message}");
 | 
			
		||||
            return StatusCode(StatusCodes.Status500InternalServerError, $"添加实验资源失败: {ex.Message}");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 获取指定实验ID的指定资源类型的所有资源的ID和名称
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="examId">实验ID</param>
 | 
			
		||||
    /// <param name="resourceType">资源类型</param>
 | 
			
		||||
    /// <returns>资源列表</returns>
 | 
			
		||||
    [Authorize]
 | 
			
		||||
    [HttpGet("{examId}/resources/{resourceType}")]
 | 
			
		||||
    [EnableCors("Users")]
 | 
			
		||||
    [ProducesResponseType(typeof(ResourceInfo[]), StatusCodes.Status200OK)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status500InternalServerError)]
 | 
			
		||||
    public IActionResult GetExamResourceList(string examId, string resourceType)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(examId) || string.IsNullOrWhiteSpace(resourceType))
 | 
			
		||||
            return BadRequest("实验ID和资源类型不能为空");
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            using var db = new Database.AppDataConnection();
 | 
			
		||||
            var result = db.GetExamResourceList(examId, resourceType);
 | 
			
		||||
 | 
			
		||||
            if (!result.IsSuccessful)
 | 
			
		||||
            {
 | 
			
		||||
                logger.Error($"获取实验资源列表时出错: {result.Error.Message}");
 | 
			
		||||
                return StatusCode(StatusCodes.Status500InternalServerError, $"获取实验资源列表失败: {result.Error.Message}");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var resources = result.Value.Select(r => new ResourceInfo
 | 
			
		||||
            {
 | 
			
		||||
                ID = r.ID,
 | 
			
		||||
                Name = r.Name
 | 
			
		||||
            }).ToArray();
 | 
			
		||||
 | 
			
		||||
            logger.Info($"成功获取实验资源列表: {examId}/{resourceType},共 {resources.Length} 个资源");
 | 
			
		||||
            return Ok(resources);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error($"获取实验资源列表 {examId}/{resourceType} 时出错: {ex.Message}");
 | 
			
		||||
            return StatusCode(StatusCodes.Status500InternalServerError, $"获取实验资源列表失败: {ex.Message}");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 根据资源ID下载资源
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="resourceId">资源ID</param>
 | 
			
		||||
    /// <returns>资源文件</returns>
 | 
			
		||||
    [HttpGet("resources/{resourceId}")]
 | 
			
		||||
    [EnableCors("Users")]
 | 
			
		||||
    [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status404NotFound)]
 | 
			
		||||
    [ProducesResponseType(StatusCodes.Status500InternalServerError)]
 | 
			
		||||
    public IActionResult GetExamResourceById(int resourceId)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            using var db = new Database.AppDataConnection();
 | 
			
		||||
            var result = db.GetExamResourceById(resourceId);
 | 
			
		||||
 | 
			
		||||
            if (!result.IsSuccessful)
 | 
			
		||||
            {
 | 
			
		||||
                logger.Error($"获取资源时出错: {result.Error.Message}");
 | 
			
		||||
                return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {result.Error.Message}");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!result.Value.HasValue)
 | 
			
		||||
            {
 | 
			
		||||
                logger.Warn($"资源不存在: {resourceId}");
 | 
			
		||||
                return NotFound($"资源 {resourceId} 不存在");
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var resource = result.Value.Value;
 | 
			
		||||
            logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})");
 | 
			
		||||
            return File(resource.Data, resource.MimeType, resource.ResourceName);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error($"获取资源 {resourceId} 时出错: {ex.Message}");
 | 
			
		||||
            return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {ex.Message}");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -162,10 +162,16 @@ public class Exam
 | 
			
		||||
    public required string ID { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 实验文档内容(Markdown格式)
 | 
			
		||||
    /// 实验名称
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public required string DocContent { get; set; }
 | 
			
		||||
    public required string Name { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 实验描述
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public required string Description { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 实验创建时间
 | 
			
		||||
@@ -178,6 +184,127 @@ public class Exam
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public DateTime UpdatedTime { get; set; } = DateTime.Now;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 实验标签(以逗号分隔的字符串)
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public string Tags { get; set; } = "";
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 实验难度(1-5,1为最简单)
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public int Difficulty { get; set; } = 1;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 普通用户是否可见
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public bool IsVisibleToUsers { get; set; } = true;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 获取标签列表
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <returns>标签数组</returns>
 | 
			
		||||
    public string[] GetTagsList()
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(Tags))
 | 
			
		||||
            return Array.Empty<string>();
 | 
			
		||||
        
 | 
			
		||||
        return Tags.Split(',', StringSplitOptions.RemoveEmptyEntries)
 | 
			
		||||
                   .Select(tag => tag.Trim())
 | 
			
		||||
                   .Where(tag => !string.IsNullOrEmpty(tag))
 | 
			
		||||
                   .ToArray();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 设置标签列表
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="tags">标签数组</param>
 | 
			
		||||
    public void SetTagsList(string[] tags)
 | 
			
		||||
    {
 | 
			
		||||
        Tags = string.Join(",", tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).Select(tag => tag.Trim()));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
/// 实验资源表(图片等)
 | 
			
		||||
/// </summary>
 | 
			
		||||
public class ExamResource
 | 
			
		||||
{
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 资源的唯一标识符
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [PrimaryKey, Identity]
 | 
			
		||||
    public int ID { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 所属实验ID
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public required string ExamID { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 资源类型(images, markdown, bitstream, diagram, project)
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public required string ResourceType { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 资源名称(包含文件扩展名)
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public required string ResourceName { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 资源的二进制数据
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public required byte[] Data { get; set; }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 资源创建时间
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public DateTime CreatedTime { get; set; } = DateTime.Now;
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 资源的MIME类型
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    [NotNull]
 | 
			
		||||
    public string MimeType { get; set; } = "application/octet-stream";
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 资源类型枚举
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    public static class ResourceTypes
 | 
			
		||||
    {
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 图片资源类型
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public const string Images = "images";
 | 
			
		||||
        
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// Markdown文档资源类型
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public const string Markdown = "markdown";
 | 
			
		||||
        
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 比特流文件资源类型
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public const string Bitstream = "bitstream";
 | 
			
		||||
        
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 原理图资源类型
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public const string Diagram = "diagram";
 | 
			
		||||
        
 | 
			
		||||
        /// <summary>
 | 
			
		||||
        /// 项目文件资源类型
 | 
			
		||||
        /// </summary>
 | 
			
		||||
        public const string Project = "project";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// <summary>
 | 
			
		||||
@@ -228,6 +355,7 @@ public class AppDataConnection : DataConnection
 | 
			
		||||
        this.CreateTable<User>();
 | 
			
		||||
        this.CreateTable<Board>();
 | 
			
		||||
        this.CreateTable<Exam>();
 | 
			
		||||
        this.CreateTable<ExamResource>();
 | 
			
		||||
        logger.Info("数据库表创建完成");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -240,6 +368,7 @@ public class AppDataConnection : DataConnection
 | 
			
		||||
        this.DropTable<User>();
 | 
			
		||||
        this.DropTable<Board>();
 | 
			
		||||
        this.DropTable<Exam>();
 | 
			
		||||
        this.DropTable<ExamResource>();
 | 
			
		||||
        logger.Warn("所有数据库表已删除");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -674,75 +803,271 @@ public class AppDataConnection : DataConnection
 | 
			
		||||
    public ITable<Exam> ExamTable => this.GetTable<Exam>();
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 扫描 exam 文件夹并更新实验数据库
 | 
			
		||||
    /// 实验资源表
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="examFolderPath">exam 文件夹的路径</param>
 | 
			
		||||
    /// <returns>更新的实验数量</returns>
 | 
			
		||||
    public int ScanAndUpdateExams(string examFolderPath)
 | 
			
		||||
    public ITable<ExamResource> ExamResourceTable => this.GetTable<ExamResource>();
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 创建新实验
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="id">实验ID</param>
 | 
			
		||||
    /// <param name="name">实验名称</param>
 | 
			
		||||
    /// <param name="description">实验描述</param>
 | 
			
		||||
    /// <param name="tags">实验标签</param>
 | 
			
		||||
    /// <param name="difficulty">实验难度</param>
 | 
			
		||||
    /// <param name="isVisibleToUsers">普通用户是否可见</param>
 | 
			
		||||
    /// <returns>创建的实验</returns>
 | 
			
		||||
    public Result<Exam> CreateExam(string id, string name, string description, string[]? tags = null, int difficulty = 1, bool isVisibleToUsers = true)
 | 
			
		||||
    {
 | 
			
		||||
        if (!Directory.Exists(examFolderPath))
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            logger.Warn($"实验文件夹不存在: {examFolderPath}");
 | 
			
		||||
            return 0;
 | 
			
		||||
            // 检查实验ID是否已存在
 | 
			
		||||
            var existingExam = this.ExamTable.Where(e => e.ID == id).FirstOrDefault();
 | 
			
		||||
            if (existingExam != null)
 | 
			
		||||
            {
 | 
			
		||||
                logger.Error($"实验ID已存在: {id}");
 | 
			
		||||
                return new(new Exception($"实验ID已存在: {id}"));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var exam = new Exam
 | 
			
		||||
            {
 | 
			
		||||
                ID = id,
 | 
			
		||||
                Name = name,
 | 
			
		||||
                Description = description,
 | 
			
		||||
                Difficulty = Math.Max(1, Math.Min(5, difficulty)),
 | 
			
		||||
                IsVisibleToUsers = isVisibleToUsers,
 | 
			
		||||
                CreatedTime = DateTime.Now,
 | 
			
		||||
                UpdatedTime = DateTime.Now
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            if (tags != null)
 | 
			
		||||
            {
 | 
			
		||||
                exam.SetTagsList(tags);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.Insert(exam);
 | 
			
		||||
            logger.Info($"新实验已创建: {id} ({name})");
 | 
			
		||||
            return new(exam);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        int updateCount = 0;
 | 
			
		||||
        var subdirectories = Directory.GetDirectories(examFolderPath);
 | 
			
		||||
 | 
			
		||||
        foreach (var examDir in subdirectories)
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            var examId = Path.GetFileName(examDir);
 | 
			
		||||
            var docPath = Path.Combine(examDir, "doc.md");
 | 
			
		||||
 | 
			
		||||
            if (!File.Exists(docPath))
 | 
			
		||||
            {
 | 
			
		||||
                logger.Warn($"实验 {examId} 缺少 doc.md 文件");
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            try
 | 
			
		||||
            {
 | 
			
		||||
                var docContent = File.ReadAllText(docPath);
 | 
			
		||||
                var existingExam = this.ExamTable.Where(e => e.ID == examId).FirstOrDefault();
 | 
			
		||||
 | 
			
		||||
                if (existingExam == null)
 | 
			
		||||
                {
 | 
			
		||||
                    // 创建新实验
 | 
			
		||||
                    var newExam = new Exam
 | 
			
		||||
                    {
 | 
			
		||||
                        ID = examId,
 | 
			
		||||
                        DocContent = docContent,
 | 
			
		||||
                        CreatedTime = DateTime.Now,
 | 
			
		||||
                        UpdatedTime = DateTime.Now
 | 
			
		||||
                    };
 | 
			
		||||
                    this.Insert(newExam);
 | 
			
		||||
                    logger.Info($"新实验已添加: {examId}");
 | 
			
		||||
                    updateCount++;
 | 
			
		||||
                }
 | 
			
		||||
                else
 | 
			
		||||
                {
 | 
			
		||||
                    // 更新现有实验
 | 
			
		||||
                    var fileLastWrite = File.GetLastWriteTime(docPath);
 | 
			
		||||
                    if (fileLastWrite > existingExam.UpdatedTime)
 | 
			
		||||
                    {
 | 
			
		||||
                        this.ExamTable
 | 
			
		||||
                            .Where(e => e.ID == examId)
 | 
			
		||||
                            .Set(e => e.DocContent, docContent)
 | 
			
		||||
                            .Set(e => e.UpdatedTime, DateTime.Now)
 | 
			
		||||
                            .Update();
 | 
			
		||||
                        logger.Info($"实验已更新: {examId}");
 | 
			
		||||
                        updateCount++;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            catch (Exception ex)
 | 
			
		||||
            {
 | 
			
		||||
                logger.Error($"处理实验 {examId} 时出错: {ex.Message}");
 | 
			
		||||
            }
 | 
			
		||||
            logger.Error($"创建实验时出错: {ex.Message}");
 | 
			
		||||
            return new(ex);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
        logger.Info($"实验扫描完成,共更新 {updateCount} 个实验");
 | 
			
		||||
        return updateCount;
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 更新实验信息
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="id">实验ID</param>
 | 
			
		||||
    /// <param name="name">实验名称</param>
 | 
			
		||||
    /// <param name="description">实验描述</param>
 | 
			
		||||
    /// <param name="tags">实验标签</param>
 | 
			
		||||
    /// <param name="difficulty">实验难度</param>
 | 
			
		||||
    /// <param name="isVisibleToUsers">普通用户是否可见</param>
 | 
			
		||||
    /// <returns>更新的记录数</returns>
 | 
			
		||||
    public Result<int> UpdateExam(string id, string? name = null, string? description = null, string[]? tags = null, int? difficulty = null, bool? isVisibleToUsers = null)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            int result = 0;
 | 
			
		||||
            
 | 
			
		||||
            if (name != null)
 | 
			
		||||
            {
 | 
			
		||||
                result += this.ExamTable.Where(e => e.ID == id).Set(e => e.Name, name).Update();
 | 
			
		||||
            }
 | 
			
		||||
            if (description != null)
 | 
			
		||||
            {
 | 
			
		||||
                result += this.ExamTable.Where(e => e.ID == id).Set(e => e.Description, description).Update();
 | 
			
		||||
            }
 | 
			
		||||
            if (tags != null)
 | 
			
		||||
            {
 | 
			
		||||
                var tagsString = string.Join(",", tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).Select(tag => tag.Trim()));
 | 
			
		||||
                result += this.ExamTable.Where(e => e.ID == id).Set(e => e.Tags, tagsString).Update();
 | 
			
		||||
            }
 | 
			
		||||
            if (difficulty.HasValue)
 | 
			
		||||
            {
 | 
			
		||||
                result += this.ExamTable.Where(e => e.ID == id).Set(e => e.Difficulty, Math.Max(1, Math.Min(5, difficulty.Value))).Update();
 | 
			
		||||
            }
 | 
			
		||||
            if (isVisibleToUsers.HasValue)
 | 
			
		||||
            {
 | 
			
		||||
                result += this.ExamTable.Where(e => e.ID == id).Set(e => e.IsVisibleToUsers, isVisibleToUsers.Value).Update();
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // 更新时间
 | 
			
		||||
            this.ExamTable.Where(e => e.ID == id).Set(e => e.UpdatedTime, DateTime.Now).Update();
 | 
			
		||||
            
 | 
			
		||||
            logger.Info($"实验已更新: {id},更新记录数: {result}");
 | 
			
		||||
            return new(result);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error($"更新实验时出错: {ex.Message}");
 | 
			
		||||
            return new(ex);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 添加实验资源
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="examId">所属实验ID</param>
 | 
			
		||||
    /// <param name="resourceType">资源类型</param>
 | 
			
		||||
    /// <param name="resourceName">资源名称</param>
 | 
			
		||||
    /// <param name="data">资源二进制数据</param>
 | 
			
		||||
    /// <param name="mimeType">MIME类型(可选,将根据文件扩展名自动确定)</param>
 | 
			
		||||
    /// <returns>创建的资源</returns>
 | 
			
		||||
    public Result<ExamResource> AddExamResource(string examId, string resourceType, string resourceName, byte[] data, string? mimeType = null)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            // 验证实验是否存在
 | 
			
		||||
            var exam = this.ExamTable.Where(e => e.ID == examId).FirstOrDefault();
 | 
			
		||||
            if (exam == null)
 | 
			
		||||
            {
 | 
			
		||||
                logger.Error($"实验不存在: {examId}");
 | 
			
		||||
                return new(new Exception($"实验不存在: {examId}"));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 检查资源是否已存在
 | 
			
		||||
            var existingResource = this.ExamResourceTable
 | 
			
		||||
                .Where(r => r.ExamID == examId && r.ResourceType == resourceType && r.ResourceName == resourceName)
 | 
			
		||||
                .FirstOrDefault();
 | 
			
		||||
            if (existingResource != null)
 | 
			
		||||
            {
 | 
			
		||||
                logger.Error($"资源已存在: {examId}/{resourceType}/{resourceName}");
 | 
			
		||||
                return new(new Exception($"资源已存在: {examId}/{resourceType}/{resourceName}"));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 如果未指定MIME类型,根据文件扩展名自动确定
 | 
			
		||||
            if (string.IsNullOrEmpty(mimeType))
 | 
			
		||||
            {
 | 
			
		||||
                var extension = Path.GetExtension(resourceName).ToLowerInvariant();
 | 
			
		||||
                mimeType = GetMimeTypeFromExtension(extension, resourceName);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var resource = new ExamResource
 | 
			
		||||
            {
 | 
			
		||||
                ExamID = examId,
 | 
			
		||||
                ResourceType = resourceType,
 | 
			
		||||
                ResourceName = resourceName,
 | 
			
		||||
                Data = data,
 | 
			
		||||
                MimeType = mimeType,
 | 
			
		||||
                CreatedTime = DateTime.Now
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            this.Insert(resource);
 | 
			
		||||
            logger.Info($"新资源已添加: {examId}/{resourceType}/{resourceName} ({data.Length} bytes)");
 | 
			
		||||
            return new(resource);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error($"添加实验资源时出错: {ex.Message}");
 | 
			
		||||
            return new(ex);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 获取指定实验ID的指定资源类型的所有资源的ID和名称
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="examId">实验ID</param>
 | 
			
		||||
    /// <param name="resourceType">资源类型</param>
 | 
			
		||||
    /// <returns>资源信息列表</returns>
 | 
			
		||||
    public Result<(int ID, string Name)[]> GetExamResourceList(string examId, string resourceType)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var resources = this.ExamResourceTable
 | 
			
		||||
                .Where(r => r.ExamID == examId && r.ResourceType == resourceType)
 | 
			
		||||
                .Select(r => new { r.ID, r.ResourceName })
 | 
			
		||||
                .ToArray();
 | 
			
		||||
 | 
			
		||||
            var result = resources.Select(r => (r.ID, r.ResourceName)).ToArray();
 | 
			
		||||
            logger.Info($"获取实验资源列表: {examId}/{resourceType},共 {result.Length} 个资源");
 | 
			
		||||
            return new(result);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error($"获取实验资源列表时出错: {ex.Message}");
 | 
			
		||||
            return new(ex);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 根据资源ID获取资源
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="resourceId">资源ID</param>
 | 
			
		||||
    /// <returns>资源数据</returns>
 | 
			
		||||
    public Result<Optional<ExamResource>> GetExamResourceById(int resourceId)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var resource = this.ExamResourceTable.Where(r => r.ID == resourceId).FirstOrDefault();
 | 
			
		||||
            
 | 
			
		||||
            if (resource == null)
 | 
			
		||||
            {
 | 
			
		||||
                logger.Info($"未找到资源: {resourceId}");
 | 
			
		||||
                return new(Optional<ExamResource>.None);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})");
 | 
			
		||||
            return new(resource);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error($"获取资源时出错: {ex.Message}");
 | 
			
		||||
            return new(ex);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 删除实验资源
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="resourceId">资源ID</param>
 | 
			
		||||
    /// <returns>删除的记录数</returns>
 | 
			
		||||
    public Result<int> DeleteExamResource(int resourceId)
 | 
			
		||||
    {
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            var result = this.ExamResourceTable.Where(r => r.ID == resourceId).Delete();
 | 
			
		||||
            logger.Info($"资源已删除: {resourceId},删除记录数: {result}");
 | 
			
		||||
            return new(result);
 | 
			
		||||
        }
 | 
			
		||||
        catch (Exception ex)
 | 
			
		||||
        {
 | 
			
		||||
            logger.Error($"删除资源时出错: {ex.Message}");
 | 
			
		||||
            return new(ex);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 根据文件扩展名获取MIME类型
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <param name="extension">文件扩展名</param>
 | 
			
		||||
    /// <param name="fileName">文件名(可选,用于特殊文件判断)</param>
 | 
			
		||||
    /// <returns>MIME类型</returns>
 | 
			
		||||
    private string GetMimeTypeFromExtension(string extension, string fileName = "")
 | 
			
		||||
    {
 | 
			
		||||
        // 特殊文件名处理
 | 
			
		||||
        if (!string.IsNullOrEmpty(fileName) && fileName.Equals("diagram.json", StringComparison.OrdinalIgnoreCase))
 | 
			
		||||
        {
 | 
			
		||||
            return "application/json";
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        return extension.ToLowerInvariant() switch
 | 
			
		||||
        {
 | 
			
		||||
            ".png" => "image/png",
 | 
			
		||||
            ".jpg" or ".jpeg" => "image/jpeg",
 | 
			
		||||
            ".gif" => "image/gif",
 | 
			
		||||
            ".bmp" => "image/bmp",
 | 
			
		||||
            ".svg" => "image/svg+xml",
 | 
			
		||||
            ".sbit" => "application/octet-stream",
 | 
			
		||||
            ".bit" => "application/octet-stream",
 | 
			
		||||
            ".bin" => "application/octet-stream",
 | 
			
		||||
            ".json" => "application/json",
 | 
			
		||||
            ".zip" => "application/zip",
 | 
			
		||||
            ".md" => "text/markdown",
 | 
			
		||||
            _ => "application/octet-stream"
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
@@ -780,4 +1105,31 @@ public class AppDataConnection : DataConnection
 | 
			
		||||
        logger.Debug($"成功获取实验信息: {examId}");
 | 
			
		||||
        return new(exams[0]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 删除所有实验
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <returns>删除的实验数量</returns>
 | 
			
		||||
    public int DeleteAllExams()
 | 
			
		||||
    {
 | 
			
		||||
        // 先删除所有实验资源
 | 
			
		||||
        var resourceDeleteCount = this.DeleteAllExamResources();
 | 
			
		||||
        logger.Info($"已删除所有实验资源,共删除 {resourceDeleteCount} 个资源");
 | 
			
		||||
        
 | 
			
		||||
        // 再删除所有实验
 | 
			
		||||
        var examDeleteCount = this.ExamTable.Delete();
 | 
			
		||||
        logger.Info($"已删除所有实验,共删除 {examDeleteCount} 个实验");
 | 
			
		||||
        return examDeleteCount;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// <summary>
 | 
			
		||||
    /// 删除所有实验资源
 | 
			
		||||
    /// </summary>
 | 
			
		||||
    /// <returns>删除的资源数量</returns>
 | 
			
		||||
    public int DeleteAllExamResources()
 | 
			
		||||
    {
 | 
			
		||||
        var deleteCount = this.ExamResourceTable.Delete();
 | 
			
		||||
        logger.Info($"已删除所有实验资源,共删除 {deleteCount} 个资源");
 | 
			
		||||
        return deleteCount;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										599
									
								
								src/APIClient.ts
									
									
									
									
									
								
							
							
						
						
									
										599
									
								
								src/APIClient.ts
									
									
									
									
									
								
							@@ -2811,17 +2811,22 @@ export class ExamClient {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 重新扫描实验文件夹并更新数据库
 | 
			
		||||
     * @return 更新结果
 | 
			
		||||
     * 创建新实验
 | 
			
		||||
     * @param request 创建实验请求
 | 
			
		||||
     * @return 创建结果
 | 
			
		||||
     */
 | 
			
		||||
    scanExams( cancelToken?: CancelToken): Promise<ScanResult> {
 | 
			
		||||
        let url_ = this.baseUrl + "/api/Exam/scan";
 | 
			
		||||
    createExam(request: CreateExamRequest, cancelToken?: CancelToken): Promise<ExamInfo> {
 | 
			
		||||
        let url_ = this.baseUrl + "/api/Exam";
 | 
			
		||||
        url_ = url_.replace(/[?&]$/, "");
 | 
			
		||||
 | 
			
		||||
        const content_ = JSON.stringify(request);
 | 
			
		||||
 | 
			
		||||
        let options_: AxiosRequestConfig = {
 | 
			
		||||
            data: content_,
 | 
			
		||||
            method: "POST",
 | 
			
		||||
            url: url_,
 | 
			
		||||
            headers: {
 | 
			
		||||
                "Content-Type": "application/json",
 | 
			
		||||
                "Accept": "application/json"
 | 
			
		||||
            },
 | 
			
		||||
            cancelToken
 | 
			
		||||
@@ -2834,11 +2839,11 @@ export class ExamClient {
 | 
			
		||||
                throw _error;
 | 
			
		||||
            }
 | 
			
		||||
        }).then((_response: AxiosResponse) => {
 | 
			
		||||
            return this.processScanExams(_response);
 | 
			
		||||
            return this.processCreateExam(_response);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected processScanExams(response: AxiosResponse): Promise<ScanResult> {
 | 
			
		||||
    protected processCreateExam(response: AxiosResponse): Promise<ExamInfo> {
 | 
			
		||||
        const status = response.status;
 | 
			
		||||
        let _headers: any = {};
 | 
			
		||||
        if (response.headers && typeof response.headers === "object") {
 | 
			
		||||
@@ -2848,12 +2853,19 @@ export class ExamClient {
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (status === 200) {
 | 
			
		||||
        if (status === 201) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result200: any = null;
 | 
			
		||||
            let resultData200  = _responseText;
 | 
			
		||||
            result200 = ScanResult.fromJS(resultData200);
 | 
			
		||||
            return Promise.resolve<ScanResult>(result200);
 | 
			
		||||
            let result201: any = null;
 | 
			
		||||
            let resultData201  = _responseText;
 | 
			
		||||
            result201 = ExamInfo.fromJS(resultData201);
 | 
			
		||||
            return Promise.resolve<ExamInfo>(result201);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 400) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result400: any = null;
 | 
			
		||||
            let resultData400  = _responseText;
 | 
			
		||||
            result400 = ProblemDetails.fromJS(resultData400);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result400);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 401) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
@@ -2869,6 +2881,13 @@ export class ExamClient {
 | 
			
		||||
            result403 = ProblemDetails.fromJS(resultData403);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result403);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 409) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result409: any = null;
 | 
			
		||||
            let resultData409  = _responseText;
 | 
			
		||||
            result409 = ProblemDetails.fromJS(resultData409);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result409);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 500) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers);
 | 
			
		||||
@@ -2877,7 +2896,278 @@ export class ExamClient {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            return throwException("An unexpected server error occurred.", status, _responseText, _headers);
 | 
			
		||||
        }
 | 
			
		||||
        return Promise.resolve<ScanResult>(null as any);
 | 
			
		||||
        return Promise.resolve<ExamInfo>(null as any);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 添加实验资源
 | 
			
		||||
     * @param examId 实验ID
 | 
			
		||||
     * @param resourceType 资源类型
 | 
			
		||||
     * @param file (optional) 资源文件
 | 
			
		||||
     * @return 添加结果
 | 
			
		||||
     */
 | 
			
		||||
    addExamResource(examId: string, resourceType: string, file: FileParameter | undefined, cancelToken?: CancelToken): Promise<ResourceInfo> {
 | 
			
		||||
        let url_ = this.baseUrl + "/api/Exam/{examId}/resources/{resourceType}";
 | 
			
		||||
        if (examId === undefined || examId === null)
 | 
			
		||||
            throw new Error("The parameter 'examId' must be defined.");
 | 
			
		||||
        url_ = url_.replace("{examId}", encodeURIComponent("" + examId));
 | 
			
		||||
        if (resourceType === undefined || resourceType === null)
 | 
			
		||||
            throw new Error("The parameter 'resourceType' must be defined.");
 | 
			
		||||
        url_ = url_.replace("{resourceType}", encodeURIComponent("" + resourceType));
 | 
			
		||||
        url_ = url_.replace(/[?&]$/, "");
 | 
			
		||||
 | 
			
		||||
        const content_ = new FormData();
 | 
			
		||||
        if (file === null || file === undefined)
 | 
			
		||||
            throw new Error("The parameter 'file' cannot be null.");
 | 
			
		||||
        else
 | 
			
		||||
            content_.append("file", file.data, file.fileName ? file.fileName : "file");
 | 
			
		||||
 | 
			
		||||
        let options_: AxiosRequestConfig = {
 | 
			
		||||
            data: content_,
 | 
			
		||||
            method: "POST",
 | 
			
		||||
            url: url_,
 | 
			
		||||
            headers: {
 | 
			
		||||
                "Accept": "application/json"
 | 
			
		||||
            },
 | 
			
		||||
            cancelToken
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return this.instance.request(options_).catch((_error: any) => {
 | 
			
		||||
            if (isAxiosError(_error) && _error.response) {
 | 
			
		||||
                return _error.response;
 | 
			
		||||
            } else {
 | 
			
		||||
                throw _error;
 | 
			
		||||
            }
 | 
			
		||||
        }).then((_response: AxiosResponse) => {
 | 
			
		||||
            return this.processAddExamResource(_response);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected processAddExamResource(response: AxiosResponse): Promise<ResourceInfo> {
 | 
			
		||||
        const status = response.status;
 | 
			
		||||
        let _headers: any = {};
 | 
			
		||||
        if (response.headers && typeof response.headers === "object") {
 | 
			
		||||
            for (const k in response.headers) {
 | 
			
		||||
                if (response.headers.hasOwnProperty(k)) {
 | 
			
		||||
                    _headers[k] = response.headers[k];
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (status === 201) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result201: any = null;
 | 
			
		||||
            let resultData201  = _responseText;
 | 
			
		||||
            result201 = ResourceInfo.fromJS(resultData201);
 | 
			
		||||
            return Promise.resolve<ResourceInfo>(result201);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 400) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result400: any = null;
 | 
			
		||||
            let resultData400  = _responseText;
 | 
			
		||||
            result400 = ProblemDetails.fromJS(resultData400);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result400);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 401) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result401: any = null;
 | 
			
		||||
            let resultData401  = _responseText;
 | 
			
		||||
            result401 = ProblemDetails.fromJS(resultData401);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result401);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 403) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result403: any = null;
 | 
			
		||||
            let resultData403  = _responseText;
 | 
			
		||||
            result403 = ProblemDetails.fromJS(resultData403);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result403);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 404) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result404: any = null;
 | 
			
		||||
            let resultData404  = _responseText;
 | 
			
		||||
            result404 = ProblemDetails.fromJS(resultData404);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result404);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 409) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result409: any = null;
 | 
			
		||||
            let resultData409  = _responseText;
 | 
			
		||||
            result409 = ProblemDetails.fromJS(resultData409);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result409);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 500) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers);
 | 
			
		||||
 | 
			
		||||
        } else if (status !== 200 && status !== 204) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            return throwException("An unexpected server error occurred.", status, _responseText, _headers);
 | 
			
		||||
        }
 | 
			
		||||
        return Promise.resolve<ResourceInfo>(null as any);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 获取指定实验ID的指定资源类型的所有资源的ID和名称
 | 
			
		||||
     * @param examId 实验ID
 | 
			
		||||
     * @param resourceType 资源类型
 | 
			
		||||
     * @return 资源列表
 | 
			
		||||
     */
 | 
			
		||||
    getExamResourceList(examId: string, resourceType: string, cancelToken?: CancelToken): Promise<ResourceInfo[]> {
 | 
			
		||||
        let url_ = this.baseUrl + "/api/Exam/{examId}/resources/{resourceType}";
 | 
			
		||||
        if (examId === undefined || examId === null)
 | 
			
		||||
            throw new Error("The parameter 'examId' must be defined.");
 | 
			
		||||
        url_ = url_.replace("{examId}", encodeURIComponent("" + examId));
 | 
			
		||||
        if (resourceType === undefined || resourceType === null)
 | 
			
		||||
            throw new Error("The parameter 'resourceType' must be defined.");
 | 
			
		||||
        url_ = url_.replace("{resourceType}", encodeURIComponent("" + resourceType));
 | 
			
		||||
        url_ = url_.replace(/[?&]$/, "");
 | 
			
		||||
 | 
			
		||||
        let options_: AxiosRequestConfig = {
 | 
			
		||||
            method: "GET",
 | 
			
		||||
            url: url_,
 | 
			
		||||
            headers: {
 | 
			
		||||
                "Accept": "application/json"
 | 
			
		||||
            },
 | 
			
		||||
            cancelToken
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return this.instance.request(options_).catch((_error: any) => {
 | 
			
		||||
            if (isAxiosError(_error) && _error.response) {
 | 
			
		||||
                return _error.response;
 | 
			
		||||
            } else {
 | 
			
		||||
                throw _error;
 | 
			
		||||
            }
 | 
			
		||||
        }).then((_response: AxiosResponse) => {
 | 
			
		||||
            return this.processGetExamResourceList(_response);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected processGetExamResourceList(response: AxiosResponse): Promise<ResourceInfo[]> {
 | 
			
		||||
        const status = response.status;
 | 
			
		||||
        let _headers: any = {};
 | 
			
		||||
        if (response.headers && typeof response.headers === "object") {
 | 
			
		||||
            for (const k in response.headers) {
 | 
			
		||||
                if (response.headers.hasOwnProperty(k)) {
 | 
			
		||||
                    _headers[k] = response.headers[k];
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (status === 200) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result200: any = null;
 | 
			
		||||
            let resultData200  = _responseText;
 | 
			
		||||
            if (Array.isArray(resultData200)) {
 | 
			
		||||
                result200 = [] as any;
 | 
			
		||||
                for (let item of resultData200)
 | 
			
		||||
                    result200!.push(ResourceInfo.fromJS(item));
 | 
			
		||||
            }
 | 
			
		||||
            else {
 | 
			
		||||
                result200 = <any>null;
 | 
			
		||||
            }
 | 
			
		||||
            return Promise.resolve<ResourceInfo[]>(result200);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 400) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result400: any = null;
 | 
			
		||||
            let resultData400  = _responseText;
 | 
			
		||||
            result400 = ProblemDetails.fromJS(resultData400);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result400);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 401) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result401: any = null;
 | 
			
		||||
            let resultData401  = _responseText;
 | 
			
		||||
            result401 = ProblemDetails.fromJS(resultData401);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result401);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 500) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers);
 | 
			
		||||
 | 
			
		||||
        } else if (status !== 200 && status !== 204) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            return throwException("An unexpected server error occurred.", status, _responseText, _headers);
 | 
			
		||||
        }
 | 
			
		||||
        return Promise.resolve<ResourceInfo[]>(null as any);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 根据资源ID下载资源
 | 
			
		||||
     * @param resourceId 资源ID
 | 
			
		||||
     * @return 资源文件
 | 
			
		||||
     */
 | 
			
		||||
    getExamResourceById(resourceId: number, cancelToken?: CancelToken): Promise<FileResponse> {
 | 
			
		||||
        let url_ = this.baseUrl + "/api/Exam/resources/{resourceId}";
 | 
			
		||||
        if (resourceId === undefined || resourceId === null)
 | 
			
		||||
            throw new Error("The parameter 'resourceId' must be defined.");
 | 
			
		||||
        url_ = url_.replace("{resourceId}", encodeURIComponent("" + resourceId));
 | 
			
		||||
        url_ = url_.replace(/[?&]$/, "");
 | 
			
		||||
 | 
			
		||||
        let options_: AxiosRequestConfig = {
 | 
			
		||||
            responseType: "blob",
 | 
			
		||||
            method: "GET",
 | 
			
		||||
            url: url_,
 | 
			
		||||
            headers: {
 | 
			
		||||
                "Accept": "application/json"
 | 
			
		||||
            },
 | 
			
		||||
            cancelToken
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return this.instance.request(options_).catch((_error: any) => {
 | 
			
		||||
            if (isAxiosError(_error) && _error.response) {
 | 
			
		||||
                return _error.response;
 | 
			
		||||
            } else {
 | 
			
		||||
                throw _error;
 | 
			
		||||
            }
 | 
			
		||||
        }).then((_response: AxiosResponse) => {
 | 
			
		||||
            return this.processGetExamResourceById(_response);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected processGetExamResourceById(response: AxiosResponse): Promise<FileResponse> {
 | 
			
		||||
        const status = response.status;
 | 
			
		||||
        let _headers: any = {};
 | 
			
		||||
        if (response.headers && typeof response.headers === "object") {
 | 
			
		||||
            for (const k in response.headers) {
 | 
			
		||||
                if (response.headers.hasOwnProperty(k)) {
 | 
			
		||||
                    _headers[k] = response.headers[k];
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (status === 200 || status === 206) {
 | 
			
		||||
            const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined;
 | 
			
		||||
            let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined;
 | 
			
		||||
            let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined;
 | 
			
		||||
            if (fileName) {
 | 
			
		||||
                fileName = decodeURIComponent(fileName);
 | 
			
		||||
            } else {
 | 
			
		||||
                fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined;
 | 
			
		||||
                fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined;
 | 
			
		||||
            }
 | 
			
		||||
            return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers });
 | 
			
		||||
        } else if (status === 400) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result400: any = null;
 | 
			
		||||
            let resultData400  = _responseText;
 | 
			
		||||
            result400 = ProblemDetails.fromJS(resultData400);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result400);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 404) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            let result404: any = null;
 | 
			
		||||
            let resultData404  = _responseText;
 | 
			
		||||
            result404 = ProblemDetails.fromJS(resultData404);
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers, result404);
 | 
			
		||||
 | 
			
		||||
        } else if (status === 500) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            return throwException("A server side error occurred.", status, _responseText, _headers);
 | 
			
		||||
 | 
			
		||||
        } else if (status !== 200 && status !== 204) {
 | 
			
		||||
            const _responseText = response.data;
 | 
			
		||||
            return throwException("An unexpected server error occurred.", status, _responseText, _headers);
 | 
			
		||||
        }
 | 
			
		||||
        return Promise.resolve<FileResponse>(null as any);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -7239,20 +7529,15 @@ export enum CaptureMode {
 | 
			
		||||
 | 
			
		||||
/** 调试器整体配置信息 */
 | 
			
		||||
export class DebuggerConfig implements IDebuggerConfig {
 | 
			
		||||
    /**            时钟频率
 | 
			
		||||
            */
 | 
			
		||||
    /** 时钟频率 */
 | 
			
		||||
    clkFreq!: number;
 | 
			
		||||
    /**            总端口数量
 | 
			
		||||
            */
 | 
			
		||||
    /** 总端口数量 */
 | 
			
		||||
    totalPortNum!: number;
 | 
			
		||||
    /**            捕获深度(采样点数)
 | 
			
		||||
            */
 | 
			
		||||
    /** 捕获深度(采样点数) */
 | 
			
		||||
    captureDepth!: number;
 | 
			
		||||
    /**            触发器数量
 | 
			
		||||
            */
 | 
			
		||||
    /** 触发器数量 */
 | 
			
		||||
    triggerNum!: number;
 | 
			
		||||
    /**            所有信号通道的配置信息
 | 
			
		||||
            */
 | 
			
		||||
    /** 所有信号通道的配置信息 */
 | 
			
		||||
    channelConfigs!: ChannelConfig[];
 | 
			
		||||
 | 
			
		||||
    constructor(data?: IDebuggerConfig) {
 | 
			
		||||
@@ -7305,42 +7590,31 @@ export class DebuggerConfig implements IDebuggerConfig {
 | 
			
		||||
 | 
			
		||||
/** 调试器整体配置信息 */
 | 
			
		||||
export interface IDebuggerConfig {
 | 
			
		||||
    /**            时钟频率
 | 
			
		||||
            */
 | 
			
		||||
    /** 时钟频率 */
 | 
			
		||||
    clkFreq: number;
 | 
			
		||||
    /**            总端口数量
 | 
			
		||||
            */
 | 
			
		||||
    /** 总端口数量 */
 | 
			
		||||
    totalPortNum: number;
 | 
			
		||||
    /**            捕获深度(采样点数)
 | 
			
		||||
            */
 | 
			
		||||
    /** 捕获深度(采样点数) */
 | 
			
		||||
    captureDepth: number;
 | 
			
		||||
    /**            触发器数量
 | 
			
		||||
            */
 | 
			
		||||
    /** 触发器数量 */
 | 
			
		||||
    triggerNum: number;
 | 
			
		||||
    /**            所有信号通道的配置信息
 | 
			
		||||
            */
 | 
			
		||||
    /** 所有信号通道的配置信息 */
 | 
			
		||||
    channelConfigs: ChannelConfig[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 表示单个信号通道的配置信息 */
 | 
			
		||||
export class ChannelConfig implements IChannelConfig {
 | 
			
		||||
    /**            通道名称
 | 
			
		||||
            */
 | 
			
		||||
    /** 通道名称 */
 | 
			
		||||
    name!: string;
 | 
			
		||||
    /**            通道显示颜色(如前端波形显示用)
 | 
			
		||||
            */
 | 
			
		||||
    /** 通道显示颜色(如前端波形显示用) */
 | 
			
		||||
    color!: string;
 | 
			
		||||
    /**            通道信号线宽度(位数)
 | 
			
		||||
            */
 | 
			
		||||
    /** 通道信号线宽度(位数) */
 | 
			
		||||
    wireWidth!: number;
 | 
			
		||||
    /**            信号线在父端口中的起始索引(bit)
 | 
			
		||||
            */
 | 
			
		||||
    /** 信号线在父端口中的起始索引(bit) */
 | 
			
		||||
    wireStartIndex!: number;
 | 
			
		||||
    /**            父端口编号
 | 
			
		||||
            */
 | 
			
		||||
    /** 父端口编号 */
 | 
			
		||||
    parentPort!: number;
 | 
			
		||||
    /**            捕获模式(如上升沿、下降沿等)
 | 
			
		||||
            */
 | 
			
		||||
    /** 捕获模式(如上升沿、下降沿等) */
 | 
			
		||||
    mode!: CaptureMode;
 | 
			
		||||
 | 
			
		||||
    constructor(data?: IChannelConfig) {
 | 
			
		||||
@@ -7384,33 +7658,25 @@ export class ChannelConfig implements IChannelConfig {
 | 
			
		||||
 | 
			
		||||
/** 表示单个信号通道的配置信息 */
 | 
			
		||||
export interface IChannelConfig {
 | 
			
		||||
    /**            通道名称
 | 
			
		||||
            */
 | 
			
		||||
    /** 通道名称 */
 | 
			
		||||
    name: string;
 | 
			
		||||
    /**            通道显示颜色(如前端波形显示用)
 | 
			
		||||
            */
 | 
			
		||||
    /** 通道显示颜色(如前端波形显示用) */
 | 
			
		||||
    color: string;
 | 
			
		||||
    /**            通道信号线宽度(位数)
 | 
			
		||||
            */
 | 
			
		||||
    /** 通道信号线宽度(位数) */
 | 
			
		||||
    wireWidth: number;
 | 
			
		||||
    /**            信号线在父端口中的起始索引(bit)
 | 
			
		||||
            */
 | 
			
		||||
    /** 信号线在父端口中的起始索引(bit) */
 | 
			
		||||
    wireStartIndex: number;
 | 
			
		||||
    /**            父端口编号
 | 
			
		||||
            */
 | 
			
		||||
    /** 父端口编号 */
 | 
			
		||||
    parentPort: number;
 | 
			
		||||
    /**            捕获模式(如上升沿、下降沿等)
 | 
			
		||||
            */
 | 
			
		||||
    /** 捕获模式(如上升沿、下降沿等) */
 | 
			
		||||
    mode: CaptureMode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 单个通道的捕获数据 */
 | 
			
		||||
export class ChannelCaptureData implements IChannelCaptureData {
 | 
			
		||||
    /**            通道名称
 | 
			
		||||
            */
 | 
			
		||||
    /** 通道名称 */
 | 
			
		||||
    name!: string;
 | 
			
		||||
    /**            通道捕获到的数据(Base64编码的UInt32数组)
 | 
			
		||||
            */
 | 
			
		||||
    /** 通道捕获到的数据(Base64编码的UInt32数组) */
 | 
			
		||||
    data!: string;
 | 
			
		||||
 | 
			
		||||
    constructor(data?: IChannelCaptureData) {
 | 
			
		||||
@@ -7446,11 +7712,9 @@ export class ChannelCaptureData implements IChannelCaptureData {
 | 
			
		||||
 | 
			
		||||
/** 单个通道的捕获数据 */
 | 
			
		||||
export interface IChannelCaptureData {
 | 
			
		||||
    /**            通道名称
 | 
			
		||||
            */
 | 
			
		||||
    /** 通道名称 */
 | 
			
		||||
    name: string;
 | 
			
		||||
    /**            通道捕获到的数据(Base64编码的UInt32数组)
 | 
			
		||||
            */
 | 
			
		||||
    /** 通道捕获到的数据(Base64编码的UInt32数组) */
 | 
			
		||||
    data: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -7458,12 +7722,18 @@ export interface IChannelCaptureData {
 | 
			
		||||
export class ExamSummary implements IExamSummary {
 | 
			
		||||
    /** 实验的唯一标识符 */
 | 
			
		||||
    id!: string;
 | 
			
		||||
    /** 实验名称 */
 | 
			
		||||
    name!: string;
 | 
			
		||||
    /** 实验创建时间 */
 | 
			
		||||
    createdTime!: Date;
 | 
			
		||||
    /** 实验最后更新时间 */
 | 
			
		||||
    updatedTime!: Date;
 | 
			
		||||
    /** 实验标题(从文档内容中提取) */
 | 
			
		||||
    title!: string;
 | 
			
		||||
    /** 实验标签 */
 | 
			
		||||
    tags!: string[];
 | 
			
		||||
    /** 实验难度(1-5) */
 | 
			
		||||
    difficulty!: number;
 | 
			
		||||
    /** 普通用户是否可见 */
 | 
			
		||||
    isVisibleToUsers!: boolean;
 | 
			
		||||
 | 
			
		||||
    constructor(data?: IExamSummary) {
 | 
			
		||||
        if (data) {
 | 
			
		||||
@@ -7472,14 +7742,24 @@ export class ExamSummary implements IExamSummary {
 | 
			
		||||
                    (<any>this)[property] = (<any>data)[property];
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!data) {
 | 
			
		||||
            this.tags = [];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    init(_data?: any) {
 | 
			
		||||
        if (_data) {
 | 
			
		||||
            this.id = _data["id"];
 | 
			
		||||
            this.name = _data["name"];
 | 
			
		||||
            this.createdTime = _data["createdTime"] ? new Date(_data["createdTime"].toString()) : <any>undefined;
 | 
			
		||||
            this.updatedTime = _data["updatedTime"] ? new Date(_data["updatedTime"].toString()) : <any>undefined;
 | 
			
		||||
            this.title = _data["title"];
 | 
			
		||||
            if (Array.isArray(_data["tags"])) {
 | 
			
		||||
                this.tags = [] as any;
 | 
			
		||||
                for (let item of _data["tags"])
 | 
			
		||||
                    this.tags!.push(item);
 | 
			
		||||
            }
 | 
			
		||||
            this.difficulty = _data["difficulty"];
 | 
			
		||||
            this.isVisibleToUsers = _data["isVisibleToUsers"];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -7493,9 +7773,16 @@ export class ExamSummary implements IExamSummary {
 | 
			
		||||
    toJSON(data?: any) {
 | 
			
		||||
        data = typeof data === 'object' ? data : {};
 | 
			
		||||
        data["id"] = this.id;
 | 
			
		||||
        data["name"] = this.name;
 | 
			
		||||
        data["createdTime"] = this.createdTime ? this.createdTime.toISOString() : <any>undefined;
 | 
			
		||||
        data["updatedTime"] = this.updatedTime ? this.updatedTime.toISOString() : <any>undefined;
 | 
			
		||||
        data["title"] = this.title;
 | 
			
		||||
        if (Array.isArray(this.tags)) {
 | 
			
		||||
            data["tags"] = [];
 | 
			
		||||
            for (let item of this.tags)
 | 
			
		||||
                data["tags"].push(item);
 | 
			
		||||
        }
 | 
			
		||||
        data["difficulty"] = this.difficulty;
 | 
			
		||||
        data["isVisibleToUsers"] = this.isVisibleToUsers;
 | 
			
		||||
        return data;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -7504,24 +7791,38 @@ export class ExamSummary implements IExamSummary {
 | 
			
		||||
export interface IExamSummary {
 | 
			
		||||
    /** 实验的唯一标识符 */
 | 
			
		||||
    id: string;
 | 
			
		||||
    /** 实验名称 */
 | 
			
		||||
    name: string;
 | 
			
		||||
    /** 实验创建时间 */
 | 
			
		||||
    createdTime: Date;
 | 
			
		||||
    /** 实验最后更新时间 */
 | 
			
		||||
    updatedTime: Date;
 | 
			
		||||
    /** 实验标题(从文档内容中提取) */
 | 
			
		||||
    title: string;
 | 
			
		||||
    /** 实验标签 */
 | 
			
		||||
    tags: string[];
 | 
			
		||||
    /** 实验难度(1-5) */
 | 
			
		||||
    difficulty: number;
 | 
			
		||||
    /** 普通用户是否可见 */
 | 
			
		||||
    isVisibleToUsers: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 实验信息类 */
 | 
			
		||||
export class ExamInfo implements IExamInfo {
 | 
			
		||||
    /** 实验的唯一标识符 */
 | 
			
		||||
    id!: string;
 | 
			
		||||
    /** 实验文档内容(Markdown格式) */
 | 
			
		||||
    docContent!: string;
 | 
			
		||||
    /** 实验名称 */
 | 
			
		||||
    name!: string;
 | 
			
		||||
    /** 实验描述 */
 | 
			
		||||
    description!: string;
 | 
			
		||||
    /** 实验创建时间 */
 | 
			
		||||
    createdTime!: Date;
 | 
			
		||||
    /** 实验最后更新时间 */
 | 
			
		||||
    updatedTime!: Date;
 | 
			
		||||
    /** 实验标签 */
 | 
			
		||||
    tags!: string[];
 | 
			
		||||
    /** 实验难度(1-5) */
 | 
			
		||||
    difficulty!: number;
 | 
			
		||||
    /** 普通用户是否可见 */
 | 
			
		||||
    isVisibleToUsers!: boolean;
 | 
			
		||||
 | 
			
		||||
    constructor(data?: IExamInfo) {
 | 
			
		||||
        if (data) {
 | 
			
		||||
@@ -7530,14 +7831,25 @@ export class ExamInfo implements IExamInfo {
 | 
			
		||||
                    (<any>this)[property] = (<any>data)[property];
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!data) {
 | 
			
		||||
            this.tags = [];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    init(_data?: any) {
 | 
			
		||||
        if (_data) {
 | 
			
		||||
            this.id = _data["id"];
 | 
			
		||||
            this.docContent = _data["docContent"];
 | 
			
		||||
            this.name = _data["name"];
 | 
			
		||||
            this.description = _data["description"];
 | 
			
		||||
            this.createdTime = _data["createdTime"] ? new Date(_data["createdTime"].toString()) : <any>undefined;
 | 
			
		||||
            this.updatedTime = _data["updatedTime"] ? new Date(_data["updatedTime"].toString()) : <any>undefined;
 | 
			
		||||
            if (Array.isArray(_data["tags"])) {
 | 
			
		||||
                this.tags = [] as any;
 | 
			
		||||
                for (let item of _data["tags"])
 | 
			
		||||
                    this.tags!.push(item);
 | 
			
		||||
            }
 | 
			
		||||
            this.difficulty = _data["difficulty"];
 | 
			
		||||
            this.isVisibleToUsers = _data["isVisibleToUsers"];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -7551,9 +7863,17 @@ export class ExamInfo implements IExamInfo {
 | 
			
		||||
    toJSON(data?: any) {
 | 
			
		||||
        data = typeof data === 'object' ? data : {};
 | 
			
		||||
        data["id"] = this.id;
 | 
			
		||||
        data["docContent"] = this.docContent;
 | 
			
		||||
        data["name"] = this.name;
 | 
			
		||||
        data["description"] = this.description;
 | 
			
		||||
        data["createdTime"] = this.createdTime ? this.createdTime.toISOString() : <any>undefined;
 | 
			
		||||
        data["updatedTime"] = this.updatedTime ? this.updatedTime.toISOString() : <any>undefined;
 | 
			
		||||
        if (Array.isArray(this.tags)) {
 | 
			
		||||
            data["tags"] = [];
 | 
			
		||||
            for (let item of this.tags)
 | 
			
		||||
                data["tags"].push(item);
 | 
			
		||||
        }
 | 
			
		||||
        data["difficulty"] = this.difficulty;
 | 
			
		||||
        data["isVisibleToUsers"] = this.isVisibleToUsers;
 | 
			
		||||
        return data;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -7562,22 +7882,111 @@ export class ExamInfo implements IExamInfo {
 | 
			
		||||
export interface IExamInfo {
 | 
			
		||||
    /** 实验的唯一标识符 */
 | 
			
		||||
    id: string;
 | 
			
		||||
    /** 实验文档内容(Markdown格式) */
 | 
			
		||||
    docContent: string;
 | 
			
		||||
    /** 实验名称 */
 | 
			
		||||
    name: string;
 | 
			
		||||
    /** 实验描述 */
 | 
			
		||||
    description: string;
 | 
			
		||||
    /** 实验创建时间 */
 | 
			
		||||
    createdTime: Date;
 | 
			
		||||
    /** 实验最后更新时间 */
 | 
			
		||||
    updatedTime: Date;
 | 
			
		||||
    /** 实验标签 */
 | 
			
		||||
    tags: string[];
 | 
			
		||||
    /** 实验难度(1-5) */
 | 
			
		||||
    difficulty: number;
 | 
			
		||||
    /** 普通用户是否可见 */
 | 
			
		||||
    isVisibleToUsers: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 扫描结果类 */
 | 
			
		||||
export class ScanResult implements IScanResult {
 | 
			
		||||
    /** 结果消息 */
 | 
			
		||||
    declare message: string;
 | 
			
		||||
    /** 更新的实验数量 */
 | 
			
		||||
    updateCount!: number;
 | 
			
		||||
/** 创建实验请求类 */
 | 
			
		||||
export class CreateExamRequest implements ICreateExamRequest {
 | 
			
		||||
    /** 实验ID */
 | 
			
		||||
    id!: string;
 | 
			
		||||
    /** 实验名称 */
 | 
			
		||||
    name!: string;
 | 
			
		||||
    /** 实验描述 */
 | 
			
		||||
    description!: string;
 | 
			
		||||
    /** 实验标签 */
 | 
			
		||||
    tags!: string[];
 | 
			
		||||
    /** 实验难度(1-5) */
 | 
			
		||||
    difficulty!: number;
 | 
			
		||||
    /** 普通用户是否可见 */
 | 
			
		||||
    isVisibleToUsers!: boolean;
 | 
			
		||||
 | 
			
		||||
    constructor(data?: IScanResult) {
 | 
			
		||||
    constructor(data?: ICreateExamRequest) {
 | 
			
		||||
        if (data) {
 | 
			
		||||
            for (var property in data) {
 | 
			
		||||
                if (data.hasOwnProperty(property))
 | 
			
		||||
                    (<any>this)[property] = (<any>data)[property];
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (!data) {
 | 
			
		||||
            this.tags = [];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    init(_data?: any) {
 | 
			
		||||
        if (_data) {
 | 
			
		||||
            this.id = _data["id"];
 | 
			
		||||
            this.name = _data["name"];
 | 
			
		||||
            this.description = _data["description"];
 | 
			
		||||
            if (Array.isArray(_data["tags"])) {
 | 
			
		||||
                this.tags = [] as any;
 | 
			
		||||
                for (let item of _data["tags"])
 | 
			
		||||
                    this.tags!.push(item);
 | 
			
		||||
            }
 | 
			
		||||
            this.difficulty = _data["difficulty"];
 | 
			
		||||
            this.isVisibleToUsers = _data["isVisibleToUsers"];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static fromJS(data: any): CreateExamRequest {
 | 
			
		||||
        data = typeof data === 'object' ? data : {};
 | 
			
		||||
        let result = new CreateExamRequest();
 | 
			
		||||
        result.init(data);
 | 
			
		||||
        return result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    toJSON(data?: any) {
 | 
			
		||||
        data = typeof data === 'object' ? data : {};
 | 
			
		||||
        data["id"] = this.id;
 | 
			
		||||
        data["name"] = this.name;
 | 
			
		||||
        data["description"] = this.description;
 | 
			
		||||
        if (Array.isArray(this.tags)) {
 | 
			
		||||
            data["tags"] = [];
 | 
			
		||||
            for (let item of this.tags)
 | 
			
		||||
                data["tags"].push(item);
 | 
			
		||||
        }
 | 
			
		||||
        data["difficulty"] = this.difficulty;
 | 
			
		||||
        data["isVisibleToUsers"] = this.isVisibleToUsers;
 | 
			
		||||
        return data;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 创建实验请求类 */
 | 
			
		||||
export interface ICreateExamRequest {
 | 
			
		||||
    /** 实验ID */
 | 
			
		||||
    id: string;
 | 
			
		||||
    /** 实验名称 */
 | 
			
		||||
    name: string;
 | 
			
		||||
    /** 实验描述 */
 | 
			
		||||
    description: string;
 | 
			
		||||
    /** 实验标签 */
 | 
			
		||||
    tags: string[];
 | 
			
		||||
    /** 实验难度(1-5) */
 | 
			
		||||
    difficulty: number;
 | 
			
		||||
    /** 普通用户是否可见 */
 | 
			
		||||
    isVisibleToUsers: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 资源信息类 */
 | 
			
		||||
export class ResourceInfo implements IResourceInfo {
 | 
			
		||||
    /** 资源ID */
 | 
			
		||||
    id!: number;
 | 
			
		||||
    /** 资源名称 */
 | 
			
		||||
    name!: string;
 | 
			
		||||
 | 
			
		||||
    constructor(data?: IResourceInfo) {
 | 
			
		||||
        if (data) {
 | 
			
		||||
            for (var property in data) {
 | 
			
		||||
                if (data.hasOwnProperty(property))
 | 
			
		||||
@@ -7588,32 +7997,32 @@ export class ScanResult implements IScanResult {
 | 
			
		||||
 | 
			
		||||
    init(_data?: any) {
 | 
			
		||||
        if (_data) {
 | 
			
		||||
            this.message = _data["message"];
 | 
			
		||||
            this.updateCount = _data["updateCount"];
 | 
			
		||||
            this.id = _data["id"];
 | 
			
		||||
            this.name = _data["name"];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static fromJS(data: any): ScanResult {
 | 
			
		||||
    static fromJS(data: any): ResourceInfo {
 | 
			
		||||
        data = typeof data === 'object' ? data : {};
 | 
			
		||||
        let result = new ScanResult();
 | 
			
		||||
        let result = new ResourceInfo();
 | 
			
		||||
        result.init(data);
 | 
			
		||||
        return result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    toJSON(data?: any) {
 | 
			
		||||
        data = typeof data === 'object' ? data : {};
 | 
			
		||||
        data["message"] = this.message;
 | 
			
		||||
        data["updateCount"] = this.updateCount;
 | 
			
		||||
        data["id"] = this.id;
 | 
			
		||||
        data["name"] = this.name;
 | 
			
		||||
        return data;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 扫描结果类 */
 | 
			
		||||
export interface IScanResult {
 | 
			
		||||
    /** 结果消息 */
 | 
			
		||||
    message: string;
 | 
			
		||||
    /** 更新的实验数量 */
 | 
			
		||||
    updateCount: number;
 | 
			
		||||
/** 资源信息类 */
 | 
			
		||||
export interface IResourceInfo {
 | 
			
		||||
    /** 资源ID */
 | 
			
		||||
    id: number;
 | 
			
		||||
    /** 资源名称 */
 | 
			
		||||
    name: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** 逻辑分析仪运行状态枚举 */
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="fixed left-1/2 top-30 z-999 -translate-x-1/2">
 | 
			
		||||
  <div class="fixed left-1/2 top-30 z-[9999] -translate-x-1/2">
 | 
			
		||||
    <transition
 | 
			
		||||
      name="alert"
 | 
			
		||||
      enter-active-class="alert-enter-active"
 | 
			
		||||
 
 | 
			
		||||
@@ -119,6 +119,7 @@
 | 
			
		||||
            componentManager.prepareComponentProps(
 | 
			
		||||
              component.attrs || {},
 | 
			
		||||
              component.id,
 | 
			
		||||
              props.examId,
 | 
			
		||||
            )
 | 
			
		||||
          "
 | 
			
		||||
          @update:bindKey="
 | 
			
		||||
@@ -175,9 +176,7 @@ import {
 | 
			
		||||
  ref,
 | 
			
		||||
  reactive,
 | 
			
		||||
  onMounted,
 | 
			
		||||
  onUnmounted,
 | 
			
		||||
  computed,
 | 
			
		||||
  watch,
 | 
			
		||||
  provide,
 | 
			
		||||
} from "vue";
 | 
			
		||||
import { useEventListener } from "@vueuse/core";
 | 
			
		||||
@@ -217,6 +216,7 @@ const emit = defineEmits(["toggle-doc-panel", "open-components"]);
 | 
			
		||||
// 定义组件接受的属性
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
  showDocPanel?: boolean; // 添加属性接收文档面板的显示状态
 | 
			
		||||
  examId?: string; // 新增examId属性
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
// 获取componentManager实例
 | 
			
		||||
@@ -977,7 +977,8 @@ function exportDiagram() {
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  // 加载图表数据
 | 
			
		||||
  try {
 | 
			
		||||
    diagramData.value = await loadDiagramData();
 | 
			
		||||
    // 传入examId参数,让diagramManager处理动态加载
 | 
			
		||||
    diagramData.value = await loadDiagramData(props.examId);
 | 
			
		||||
 | 
			
		||||
    // 预加载所有组件模块
 | 
			
		||||
    const componentTypes = new Set<string>();
 | 
			
		||||
 
 | 
			
		||||
@@ -763,11 +763,15 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
 | 
			
		||||
    function prepareComponentProps(
 | 
			
		||||
      attrs: Record<string, any>,
 | 
			
		||||
      componentId?: string,
 | 
			
		||||
      examId?: string,
 | 
			
		||||
    ): Record<string, any> {
 | 
			
		||||
      const result: Record<string, any> = { ...attrs };
 | 
			
		||||
      if (componentId) {
 | 
			
		||||
        result.componentId = componentId;
 | 
			
		||||
      }
 | 
			
		||||
      if (examId) {
 | 
			
		||||
        result.examId = examId;
 | 
			
		||||
      }
 | 
			
		||||
      return result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -26,6 +26,8 @@ export interface DiagramPart {
 | 
			
		||||
// 连接类型定义 - 使用元组类型表示四元素数组
 | 
			
		||||
export type ConnectionArray = [string, string, number, string[]];
 | 
			
		||||
 | 
			
		||||
import { AuthManager } from '@/utils/AuthManager';
 | 
			
		||||
 | 
			
		||||
// 解析连接字符串为组件ID和引脚ID
 | 
			
		||||
export function parseConnectionPin(connectionPin: string): { componentId: string; pinId: string } {
 | 
			
		||||
  const [componentId, pinId] = connectionPin.split(':');
 | 
			
		||||
@@ -80,22 +82,72 @@ export interface WireItem {
 | 
			
		||||
  showLabel: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 从本地存储加载图表数据
 | 
			
		||||
export async function loadDiagramData(): Promise<DiagramData> {
 | 
			
		||||
// 从本地存储或动态API加载图表数据
 | 
			
		||||
export async function loadDiagramData(examId?: string): Promise<DiagramData> {
 | 
			
		||||
  try {
 | 
			
		||||
    // 先尝试从本地存储加载
 | 
			
		||||
    const savedData = localStorage.getItem('diagramData');
 | 
			
		||||
    if (savedData) {
 | 
			
		||||
      return JSON.parse(savedData);
 | 
			
		||||
    // 如果提供了examId,优先从API加载实验的diagram
 | 
			
		||||
    if (examId) {
 | 
			
		||||
      try {
 | 
			
		||||
        const examClient = AuthManager.createAuthenticatedExamClient();
 | 
			
		||||
        
 | 
			
		||||
        // 获取diagram类型的资源列表
 | 
			
		||||
        const resources = await examClient.getExamResourceList(examId, 'canvas');
 | 
			
		||||
        
 | 
			
		||||
        if (resources && resources.length > 0) {
 | 
			
		||||
          // 获取第一个diagram资源
 | 
			
		||||
          const diagramResource = resources[0];
 | 
			
		||||
          
 | 
			
		||||
          // 使用动态API获取资源文件内容
 | 
			
		||||
          const response = await examClient.getExamResourceById(diagramResource.id);
 | 
			
		||||
          
 | 
			
		||||
          if (response && response.data) {
 | 
			
		||||
            const text = await response.data.text();
 | 
			
		||||
            const data = JSON.parse(text);
 | 
			
		||||
            
 | 
			
		||||
            // 验证数据格式
 | 
			
		||||
            const validation = validateDiagramData(data);
 | 
			
		||||
            if (validation.isValid) {
 | 
			
		||||
              console.log('成功从API加载实验diagram:', examId);
 | 
			
		||||
              return data;
 | 
			
		||||
            } else {
 | 
			
		||||
              console.warn('API返回的diagram数据格式无效:', validation.errors);
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          console.log('未找到实验diagram资源,使用默认加载方式');
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.warn('从API加载实验diagram失败,使用默认加载方式:', error);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // 如果本地存储没有,从文件加载
 | 
			
		||||
    // 如果没有examId或API加载失败,尝试从本地存储加载
 | 
			
		||||
    const savedData = localStorage.getItem('diagramData');
 | 
			
		||||
    if (savedData) {
 | 
			
		||||
      const data = JSON.parse(savedData);
 | 
			
		||||
      const validation = validateDiagramData(data);
 | 
			
		||||
      if (validation.isValid) {
 | 
			
		||||
        return data;
 | 
			
		||||
      } else {
 | 
			
		||||
        console.warn('本地存储的diagram数据格式无效:', validation.errors);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // 如果本地存储也没有,从静态文件加载(作为最后的备选)
 | 
			
		||||
    const response = await fetch('/src/components/diagram.json');
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      throw new Error(`Failed to load diagram.json: ${response.statusText}`);
 | 
			
		||||
    }
 | 
			
		||||
    const data = await response.json();
 | 
			
		||||
    return data;
 | 
			
		||||
    
 | 
			
		||||
    // 验证静态文件数据
 | 
			
		||||
    const validation = validateDiagramData(data);
 | 
			
		||||
    if (validation.isValid) {
 | 
			
		||||
      return data;
 | 
			
		||||
    } else {
 | 
			
		||||
      console.warn('静态diagram文件数据格式无效:', validation.errors);
 | 
			
		||||
      throw new Error('所有diagram数据源都无效');
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Error loading diagram data:', error);
 | 
			
		||||
    // 返回空的默认数据结构
 | 
			
		||||
 
 | 
			
		||||
@@ -588,9 +588,9 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const forceCapture = async () => {
 | 
			
		||||
      // 检查是否有其他操作正在进行
 | 
			
		||||
      if (operationMutex.isLocked()) {
 | 
			
		||||
        alert.warn("有其他操作正在进行中,请稍后再试", 3000);
 | 
			
		||||
      // 检查是否正在捕获
 | 
			
		||||
      if (!isCapturing.value) {
 | 
			
		||||
        alert.warn("当前没有正在进行的捕获操作", 2000);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,305 +1,324 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div 
 | 
			
		||||
    class="tutorial-carousel relative"
 | 
			
		||||
    @wheel.prevent="handleWheel"
 | 
			
		||||
    @mouseenter="pauseAutoRotation"
 | 
			
		||||
    @mouseleave="resumeAutoRotation"
 | 
			
		||||
  >    <!-- 例程卡片堆叠 -->
 | 
			
		||||
    <div class="card-stack relative mx-auto">
 | 
			
		||||
      <div 
 | 
			
		||||
        v-for="(tutorial, index) in tutorials" 
 | 
			
		||||
        :key="index" 
 | 
			
		||||
        class="tutorial-card absolute transition-all duration-500 ease-in-out rounded-2xl shadow-2xl border-4 border-base-300 overflow-hidden"
 | 
			
		||||
        :class="getCardClass(index)"
 | 
			
		||||
        :style="getCardStyle(index)"
 | 
			
		||||
        @click="handleCardClick(index, tutorial.id)"
 | 
			
		||||
      >
 | 
			
		||||
        <!-- 卡片内容 -->
 | 
			
		||||
        <div class="relative">
 | 
			
		||||
          <!-- 图片 -->          <img 
 | 
			
		||||
            :src="tutorial.thumbnail || `https://placehold.co/600x400?text=${tutorial.title}`" 
 | 
			
		||||
            class="w-full object-contain"
 | 
			
		||||
            :alt="tutorial.title"
 | 
			
		||||
            style="width: 600px; height: 400px;"
 | 
			
		||||
          />
 | 
			
		||||
          
 | 
			
		||||
          <!-- 卡片蒙层 -->
 | 
			
		||||
          <div 
 | 
			
		||||
            class="absolute inset-0 bg-primary opacity-20 transition-opacity duration-300"
 | 
			
		||||
            :class="{'opacity-10': index === currentIndex}"
 | 
			
		||||
          ></div>
 | 
			
		||||
          
 | 
			
		||||
          <!-- 标题覆盖层 -->
 | 
			
		||||
          <div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent">
 | 
			
		||||
            <h3 class="text-lg font-bold text-base-content">{{ tutorial.title }}</h3>
 | 
			
		||||
            <p class="text-sm opacity-80 truncate">{{ tutorial.description }}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <!-- 导航指示器 -->
 | 
			
		||||
    <div class="indicators flex justify-center gap-2 mt-4">
 | 
			
		||||
      <button 
 | 
			
		||||
        v-for="(_, index) in tutorials" 
 | 
			
		||||
        :key="index"
 | 
			
		||||
        @click="setActiveCard(index)"
 | 
			
		||||
        class="w-3 h-3 rounded-full transition-all duration-300"
 | 
			
		||||
        :class="index === currentIndex ? 'bg-primary scale-125' : 'bg-base-300'"
 | 
			
		||||
      ></button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, onMounted, onUnmounted } from 'vue';
 | 
			
		||||
import { useRouter } from 'vue-router';
 | 
			
		||||
 | 
			
		||||
// 接口定义
 | 
			
		||||
interface Tutorial {
 | 
			
		||||
  id: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  thumbnail?: string;
 | 
			
		||||
  docPath: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Props
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
  autoRotationInterval?: number;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
// 配置默认值
 | 
			
		||||
const autoRotationInterval = props.autoRotationInterval || 5000; // 默认5秒
 | 
			
		||||
 | 
			
		||||
// 状态管理
 | 
			
		||||
const tutorials = ref<Tutorial[]>([]);
 | 
			
		||||
const currentIndex = ref(0);
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
let autoRotationTimer: number | null = null;
 | 
			
		||||
 | 
			
		||||
// 处理卡片点击
 | 
			
		||||
const handleCardClick = (index: number, tutorialId: string) => {
 | 
			
		||||
  if (index === currentIndex.value) {
 | 
			
		||||
    goToTutorial(tutorialId);
 | 
			
		||||
  } else {
 | 
			
		||||
    setActiveCard(index);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 从 public/doc 目录加载例程信息
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    // 尝试从API获取教程目录
 | 
			
		||||
    let tutorialIds: string[] = [];
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch('/api/tutorial');
 | 
			
		||||
      if (response.ok) {
 | 
			
		||||
        const data = await response.json();
 | 
			
		||||
        tutorialIds = data.tutorials || [];
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.warn('无法从API获取教程目录,使用默认值:', error);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // 如果API调用失败或返回空列表,使用默认值
 | 
			
		||||
    if (tutorialIds.length === 0) {
 | 
			
		||||
      console.log('使用默认教程列表');
 | 
			
		||||
      tutorialIds = ['01', '02', '03', '04', '05', '06', '11', '12', '13']; // 默认例程
 | 
			
		||||
    } else {
 | 
			
		||||
      console.log('使用API获取的教程列表:', tutorialIds);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // 为每个例程创建对象并尝试获取文档标题
 | 
			
		||||
    const tutorialPromises = tutorialIds.map(async (id) => {
 | 
			
		||||
      // 尝试读取doc.md获取标题
 | 
			
		||||
      let title = `例程 ${id}`;
 | 
			
		||||
      let description = "点击加载此例程";
 | 
			
		||||
      let thumbnail = `/doc/${id}/cover.png`; // 默认使用第一张图片作为缩略图
 | 
			
		||||
      
 | 
			
		||||
      try {
 | 
			
		||||
        // 尝试读取文档内容获取标题
 | 
			
		||||
        const response = await fetch(`/doc/${id}/doc.md`);
 | 
			
		||||
        if (response.ok) {
 | 
			
		||||
          const text = await response.text();
 | 
			
		||||
          // 从Markdown提取标题
 | 
			
		||||
          const titleMatch = text.match(/^#\s+(.+)$/m);
 | 
			
		||||
          if (titleMatch && titleMatch[1]) {
 | 
			
		||||
            title = titleMatch[1].trim();
 | 
			
		||||
          }
 | 
			
		||||
          
 | 
			
		||||
          // 提取第一段作为描述
 | 
			
		||||
          const descMatch = text.match(/\n\n([^#\n][^\n]+)/);
 | 
			
		||||
          if (descMatch && descMatch[1]) {
 | 
			
		||||
            description = descMatch[1].substring(0, 100).trim();
 | 
			
		||||
            if (description.length === 100) description += '...';
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.warn(`无法读取例程${id}的文档内容:`, error);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        id,
 | 
			
		||||
        title,
 | 
			
		||||
        description,
 | 
			
		||||
        thumbnail,
 | 
			
		||||
        docPath: `/doc/${id}/doc.md`
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    tutorials.value = await Promise.all(tutorialPromises);
 | 
			
		||||
    
 | 
			
		||||
    // 启动自动旋转
 | 
			
		||||
    startAutoRotation();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('加载例程失败:', error);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// 在组件销毁时清除计时器
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
  if (autoRotationTimer) {
 | 
			
		||||
    clearInterval(autoRotationTimer);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// 鼠标滚轮处理
 | 
			
		||||
const handleWheel = (event: WheelEvent) => {
 | 
			
		||||
  if (event.deltaY > 0) {
 | 
			
		||||
    nextCard();
 | 
			
		||||
  } else {
 | 
			
		||||
    prevCard();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 下一张卡片
 | 
			
		||||
const nextCard = () => {
 | 
			
		||||
  currentIndex.value = (currentIndex.value + 1) % tutorials.value.length;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 上一张卡片
 | 
			
		||||
const prevCard = () => {
 | 
			
		||||
  currentIndex.value = (currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 设置活动卡片
 | 
			
		||||
const setActiveCard = (index: number) => {
 | 
			
		||||
  currentIndex.value = index;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 自动旋转
 | 
			
		||||
const startAutoRotation = () => {
 | 
			
		||||
  autoRotationTimer = window.setInterval(() => {
 | 
			
		||||
    nextCard();
 | 
			
		||||
  }, autoRotationInterval);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 暂停自动旋转
 | 
			
		||||
const pauseAutoRotation = () => {
 | 
			
		||||
  if (autoRotationTimer) {
 | 
			
		||||
    clearInterval(autoRotationTimer);
 | 
			
		||||
    autoRotationTimer = null;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 恢复自动旋转
 | 
			
		||||
const resumeAutoRotation = () => {
 | 
			
		||||
  if (!autoRotationTimer) {
 | 
			
		||||
    startAutoRotation();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 前往例程
 | 
			
		||||
const goToTutorial = (tutorialId: string) => {
 | 
			
		||||
  // 跳转到工程页面,并通过 query 参数传递文档路径
 | 
			
		||||
  router.push({
 | 
			
		||||
    path: '/project',
 | 
			
		||||
    query: { tutorial: tutorialId }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 计算卡片类和样式
 | 
			
		||||
const getCardClass = (index: number) => {
 | 
			
		||||
  const isActive = index === currentIndex.value;
 | 
			
		||||
  const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
 | 
			
		||||
  const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
 | 
			
		||||
  
 | 
			
		||||
  return {
 | 
			
		||||
    'z-30': isActive,
 | 
			
		||||
    'z-20': isPrev || isNext,
 | 
			
		||||
    'z-10': !isActive && !isPrev && !isNext,
 | 
			
		||||
    'hover:scale-105': isActive,
 | 
			
		||||
    'cursor-pointer': true
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getCardStyle = (index: number) => {
 | 
			
		||||
  const isActive = index === currentIndex.value;
 | 
			
		||||
  const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
 | 
			
		||||
  const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
 | 
			
		||||
  
 | 
			
		||||
  // 基本样式
 | 
			
		||||
  let style = {
 | 
			
		||||
    transform: 'scale(1) translateY(0) rotate(0deg)',
 | 
			
		||||
    opacity: '1',
 | 
			
		||||
    filter: 'blur(0)'
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  // 活动卡片
 | 
			
		||||
  if (isActive) {
 | 
			
		||||
    return style;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // 上一张卡片
 | 
			
		||||
  if (isPrev) {
 | 
			
		||||
    style.transform = 'scale(0.85) translateY(-10%) rotate(-5deg)';
 | 
			
		||||
    style.opacity = '0.7';
 | 
			
		||||
    style.filter = 'blur(1px)';
 | 
			
		||||
    return style;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // 下一张卡片
 | 
			
		||||
  if (isNext) {
 | 
			
		||||
    style.transform = 'scale(0.85) translateY(10%) rotate(5deg)';
 | 
			
		||||
    style.opacity = '0.7';
 | 
			
		||||
    style.filter = 'blur(1px)';
 | 
			
		||||
    return style;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // 其他卡片
 | 
			
		||||
  style.transform = 'scale(0.7) translateY(0) rotate(0deg)';
 | 
			
		||||
  style.opacity = '0.4';
 | 
			
		||||
  style.filter = 'blur(2px)';
 | 
			
		||||
  return style;
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.tutorial-carousel {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 500px;
 | 
			
		||||
  perspective: 1000px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card-stack {
 | 
			
		||||
  width: 600px;
 | 
			
		||||
  height: 440px;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  transform-style: preserve-3d;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tutorial-card {
 | 
			
		||||
  width: 600px;
 | 
			
		||||
  height: 400px;
 | 
			
		||||
  background-color: hsl(var(--b2));
 | 
			
		||||
  will-change: transform, opacity;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tutorial-card:hover {
 | 
			
		||||
  box-shadow: 0 0 15px rgba(var(--p), 0.5);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
<template>
 | 
			
		||||
  <div 
 | 
			
		||||
    class="tutorial-carousel relative"
 | 
			
		||||
    @wheel.prevent="handleWheel"
 | 
			
		||||
    @mouseenter="pauseAutoRotation"
 | 
			
		||||
    @mouseleave="resumeAutoRotation"
 | 
			
		||||
  >    <!-- 例程卡片堆叠 -->
 | 
			
		||||
    <div class="card-stack relative mx-auto">
 | 
			
		||||
      <div 
 | 
			
		||||
        v-for="(tutorial, index) in tutorials" 
 | 
			
		||||
        :key="index" 
 | 
			
		||||
        class="tutorial-card absolute transition-all duration-500 ease-in-out rounded-2xl shadow-2xl border-4 border-base-300 overflow-hidden"
 | 
			
		||||
        :class="getCardClass(index)"
 | 
			
		||||
        :style="getCardStyle(index)"
 | 
			
		||||
        @click="handleCardClick(index, tutorial.id)"
 | 
			
		||||
      >
 | 
			
		||||
        <!-- 卡片内容 -->
 | 
			
		||||
        <div class="relative">
 | 
			
		||||
          <!-- 图片 -->          <img 
 | 
			
		||||
            :src="tutorial.thumbnail || `https://placehold.co/600x400?text=${tutorial.title}`" 
 | 
			
		||||
            class="w-full object-contain"
 | 
			
		||||
            :alt="tutorial.title"
 | 
			
		||||
            style="width: 600px; height: 400px;"
 | 
			
		||||
          />
 | 
			
		||||
          
 | 
			
		||||
          <!-- 卡片蒙层 -->
 | 
			
		||||
          <div 
 | 
			
		||||
            class="absolute inset-0 bg-primary opacity-20 transition-opacity duration-300"
 | 
			
		||||
            :class="{'opacity-10': index === currentIndex}"
 | 
			
		||||
          ></div>
 | 
			
		||||
          
 | 
			
		||||
          <!-- 标题覆盖层 -->
 | 
			
		||||
          <div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent">
 | 
			
		||||
            <div class="flex flex-col gap-2">
 | 
			
		||||
              <h3 class="text-lg font-bold text-base-content">{{ tutorial.title }}</h3>
 | 
			
		||||
              <p class="text-sm opacity-80 truncate">{{ tutorial.description }}</p>
 | 
			
		||||
              <!-- 标签显示 -->
 | 
			
		||||
              <div v-if="tutorial.tags && tutorial.tags.length > 0" class="flex flex-wrap gap-1">
 | 
			
		||||
                <span 
 | 
			
		||||
                  v-for="tag in tutorial.tags.slice(0, 3)" 
 | 
			
		||||
                  :key="tag" 
 | 
			
		||||
                  class="badge badge-outline badge-xs text-xs"
 | 
			
		||||
                >
 | 
			
		||||
                  {{ tag }}
 | 
			
		||||
                </span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    
 | 
			
		||||
    <!-- 导航指示器 -->
 | 
			
		||||
    <div class="indicators flex justify-center gap-2 mt-4">
 | 
			
		||||
      <button 
 | 
			
		||||
        v-for="(_, index) in tutorials" 
 | 
			
		||||
        :key="index"
 | 
			
		||||
        @click="setActiveCard(index)"
 | 
			
		||||
        class="w-3 h-3 rounded-full transition-all duration-300"
 | 
			
		||||
        :class="index === currentIndex ? 'bg-primary scale-125' : 'bg-base-300'"
 | 
			
		||||
      ></button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, onMounted, onUnmounted } from 'vue';
 | 
			
		||||
import { useRouter } from 'vue-router';
 | 
			
		||||
import { AuthManager } from '@/utils/AuthManager';
 | 
			
		||||
import type { ExamSummary } from '@/APIClient';
 | 
			
		||||
 | 
			
		||||
// 接口定义
 | 
			
		||||
interface Tutorial {
 | 
			
		||||
  id: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  thumbnail?: string;
 | 
			
		||||
  tags: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Props
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
  autoRotationInterval?: number;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
// 配置默认值
 | 
			
		||||
const autoRotationInterval = props.autoRotationInterval || 5000; // 默认5秒
 | 
			
		||||
 | 
			
		||||
// 状态管理
 | 
			
		||||
const tutorials = ref<Tutorial[]>([]);
 | 
			
		||||
const currentIndex = ref(0);
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
let autoRotationTimer: number | null = null;
 | 
			
		||||
 | 
			
		||||
// 处理卡片点击
 | 
			
		||||
const handleCardClick = (index: number, tutorialId: string) => {
 | 
			
		||||
  if (index === currentIndex.value) {
 | 
			
		||||
    goToExam(tutorialId);
 | 
			
		||||
  } else {
 | 
			
		||||
    setActiveCard(index);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 从数据库加载实验数据
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    console.log('正在从数据库加载实验数据...');
 | 
			
		||||
    
 | 
			
		||||
    // 创建认证客户端
 | 
			
		||||
    const client = AuthManager.createAuthenticatedExamClient();
 | 
			
		||||
    
 | 
			
		||||
    // 获取实验列表
 | 
			
		||||
    const examList: ExamSummary[] = await client.getExamList();
 | 
			
		||||
    
 | 
			
		||||
    // 筛选可见的实验并转换为Tutorial格式
 | 
			
		||||
    const visibleExams = examList
 | 
			
		||||
      .filter(exam => exam.isVisibleToUsers)
 | 
			
		||||
      .slice(0, 6); // 限制轮播显示最多6个实验
 | 
			
		||||
    
 | 
			
		||||
    if (visibleExams.length === 0) {
 | 
			
		||||
      console.warn('没有找到可见的实验');
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // 转换数据格式并获取封面图片
 | 
			
		||||
    const tutorialPromises = visibleExams.map(async (exam) => {
 | 
			
		||||
      let thumbnail: string | undefined;
 | 
			
		||||
      
 | 
			
		||||
      try {
 | 
			
		||||
        // 获取实验的封面资源
 | 
			
		||||
        const resourceList = await client.getExamResourceList(exam.id, 'cover');
 | 
			
		||||
        if (resourceList && resourceList.length > 0) {
 | 
			
		||||
          // 使用第一个封面资源
 | 
			
		||||
          const coverResource = resourceList[0];
 | 
			
		||||
          const fileResponse = await client.getExamResourceById(coverResource.id);
 | 
			
		||||
          // 创建Blob URL作为缩略图
 | 
			
		||||
          thumbnail = URL.createObjectURL(fileResponse.data);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.warn(`无法获取实验${exam.id}的封面图片:`, error);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        id: exam.id,
 | 
			
		||||
        title: exam.name,
 | 
			
		||||
        description: '点击查看实验详情',
 | 
			
		||||
        thumbnail,
 | 
			
		||||
        tags: exam.tags || []
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    tutorials.value = await Promise.all(tutorialPromises);
 | 
			
		||||
    
 | 
			
		||||
    console.log('成功加载实验数据:', tutorials.value.length, '个实验');
 | 
			
		||||
    
 | 
			
		||||
    // 启动自动旋转
 | 
			
		||||
    startAutoRotation();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('加载实验数据失败:', error);
 | 
			
		||||
    
 | 
			
		||||
    // 如果加载失败,显示默认的占位内容
 | 
			
		||||
    tutorials.value = [{
 | 
			
		||||
      id: 'placeholder',
 | 
			
		||||
      title: '实验数据加载中...',
 | 
			
		||||
      description: '请稍后或刷新页面重试',
 | 
			
		||||
      thumbnail: undefined,
 | 
			
		||||
      tags: []
 | 
			
		||||
    }];
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// 在组件销毁时清除计时器和Blob URLs
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
  if (autoRotationTimer) {
 | 
			
		||||
    clearInterval(autoRotationTimer);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // 清理创建的Blob URLs
 | 
			
		||||
  tutorials.value.forEach(tutorial => {
 | 
			
		||||
    if (tutorial.thumbnail && tutorial.thumbnail.startsWith('blob:')) {
 | 
			
		||||
      URL.revokeObjectURL(tutorial.thumbnail);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// 鼠标滚轮处理
 | 
			
		||||
const handleWheel = (event: WheelEvent) => {
 | 
			
		||||
  if (event.deltaY > 0) {
 | 
			
		||||
    nextCard();
 | 
			
		||||
  } else {
 | 
			
		||||
    prevCard();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 下一张卡片
 | 
			
		||||
const nextCard = () => {
 | 
			
		||||
  currentIndex.value = (currentIndex.value + 1) % tutorials.value.length;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 上一张卡片
 | 
			
		||||
const prevCard = () => {
 | 
			
		||||
  currentIndex.value = (currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 设置活动卡片
 | 
			
		||||
const setActiveCard = (index: number) => {
 | 
			
		||||
  currentIndex.value = index;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 自动旋转
 | 
			
		||||
const startAutoRotation = () => {
 | 
			
		||||
  autoRotationTimer = window.setInterval(() => {
 | 
			
		||||
    nextCard();
 | 
			
		||||
  }, autoRotationInterval);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 暂停自动旋转
 | 
			
		||||
const pauseAutoRotation = () => {
 | 
			
		||||
  if (autoRotationTimer) {
 | 
			
		||||
    clearInterval(autoRotationTimer);
 | 
			
		||||
    autoRotationTimer = null;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 恢复自动旋转
 | 
			
		||||
const resumeAutoRotation = () => {
 | 
			
		||||
  if (!autoRotationTimer) {
 | 
			
		||||
    startAutoRotation();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 前往实验
 | 
			
		||||
const goToExam = (examId: string) => {
 | 
			
		||||
  // 跳转到实验列表页面并传递examId参数,页面将自动打开对应的实验详情模态框
 | 
			
		||||
  router.push({
 | 
			
		||||
    path: '/exam',
 | 
			
		||||
    query: { examId: examId }
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 计算卡片类和样式
 | 
			
		||||
const getCardClass = (index: number) => {
 | 
			
		||||
  const isActive = index === currentIndex.value;
 | 
			
		||||
  const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
 | 
			
		||||
  const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
 | 
			
		||||
  
 | 
			
		||||
  return {
 | 
			
		||||
    'z-30': isActive,
 | 
			
		||||
    'z-20': isPrev || isNext,
 | 
			
		||||
    'z-10': !isActive && !isPrev && !isNext,
 | 
			
		||||
    'hover:scale-105': isActive,
 | 
			
		||||
    'cursor-pointer': true
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getCardStyle = (index: number) => {
 | 
			
		||||
  const isActive = index === currentIndex.value;
 | 
			
		||||
  const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
 | 
			
		||||
  const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
 | 
			
		||||
  
 | 
			
		||||
  // 基本样式
 | 
			
		||||
  let style = {
 | 
			
		||||
    transform: 'scale(1) translateY(0) rotate(0deg)',
 | 
			
		||||
    opacity: '1',
 | 
			
		||||
    filter: 'blur(0)'
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  // 活动卡片
 | 
			
		||||
  if (isActive) {
 | 
			
		||||
    return style;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // 上一张卡片
 | 
			
		||||
  if (isPrev) {
 | 
			
		||||
    style.transform = 'scale(0.85) translateY(-10%) rotate(-5deg)';
 | 
			
		||||
    style.opacity = '0.7';
 | 
			
		||||
    style.filter = 'blur(1px)';
 | 
			
		||||
    return style;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // 下一张卡片
 | 
			
		||||
  if (isNext) {
 | 
			
		||||
    style.transform = 'scale(0.85) translateY(10%) rotate(5deg)';
 | 
			
		||||
    style.opacity = '0.7';
 | 
			
		||||
    style.filter = 'blur(1px)';
 | 
			
		||||
    return style;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // 其他卡片
 | 
			
		||||
  style.transform = 'scale(0.7) translateY(0) rotate(0deg)';
 | 
			
		||||
  style.opacity = '0.4';
 | 
			
		||||
  style.filter = 'blur(2px)';
 | 
			
		||||
  return style;
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.tutorial-carousel {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 500px;
 | 
			
		||||
  perspective: 1000px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card-stack {
 | 
			
		||||
  width: 600px;
 | 
			
		||||
  height: 440px;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  transform-style: preserve-3d;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tutorial-card {
 | 
			
		||||
  width: 600px;
 | 
			
		||||
  height: 400px;
 | 
			
		||||
  background-color: hsl(var(--b2));
 | 
			
		||||
  will-change: transform, opacity;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tutorial-card:hover {
 | 
			
		||||
  box-shadow: 0 0 15px rgba(var(--p), 0.5);
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,21 +1,64 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex flex-col bg-base-100 justify-center items-center">
 | 
			
		||||
  <div class="flex flex-col bg-base-100 justify-center items-center gap-4">
 | 
			
		||||
    <!-- Title -->
 | 
			
		||||
    <h1 class="font-bold text-2xl">上传比特流文件</h1>
 | 
			
		||||
    <h1 class="font-bold text-2xl">比特流文件</h1>
 | 
			
		||||
 | 
			
		||||
    <!-- 示例比特流下载区域 (仅在有examId时显示) -->
 | 
			
		||||
    <div v-if="examId && availableBitstreams.length > 0" class="w-full">
 | 
			
		||||
      <fieldset class="fieldset w-full">
 | 
			
		||||
        <legend class="fieldset-legend text-sm">示例比特流文件</legend>
 | 
			
		||||
        <div class="space-y-2">
 | 
			
		||||
          <div v-for="bitstream in availableBitstreams" :key="bitstream.id" class="flex items-center justify-between p-2 bg-base-200 rounded">
 | 
			
		||||
            <span class="text-sm">{{ bitstream.name }}</span>
 | 
			
		||||
            <div class="flex gap-2">
 | 
			
		||||
              <button 
 | 
			
		||||
                @click="downloadExampleBitstream(bitstream)" 
 | 
			
		||||
                class="btn btn-sm btn-secondary"
 | 
			
		||||
                :disabled="isDownloading || isProgramming"
 | 
			
		||||
              >
 | 
			
		||||
                <div v-if="isDownloading">
 | 
			
		||||
                  <span class="loading loading-spinner loading-xs"></span>
 | 
			
		||||
                  下载中...
 | 
			
		||||
                </div>
 | 
			
		||||
                <div v-else>
 | 
			
		||||
                  下载示例
 | 
			
		||||
                </div>
 | 
			
		||||
              </button>
 | 
			
		||||
              <button 
 | 
			
		||||
                @click="programExampleBitstream(bitstream)" 
 | 
			
		||||
                class="btn btn-sm btn-primary"
 | 
			
		||||
                :disabled="isDownloading || isProgramming || !uploadEvent"
 | 
			
		||||
              >
 | 
			
		||||
                <div v-if="isProgramming">
 | 
			
		||||
                  <span class="loading loading-spinner loading-xs"></span>
 | 
			
		||||
                  烧录中...
 | 
			
		||||
                </div>
 | 
			
		||||
                <div v-else>
 | 
			
		||||
                  直接烧录
 | 
			
		||||
                </div>
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </fieldset>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- 分割线 -->
 | 
			
		||||
    <div v-if="examId && availableBitstreams.length > 0" class="divider">或</div>
 | 
			
		||||
 | 
			
		||||
    <!-- Input File -->
 | 
			
		||||
    <fieldset class="fieldset w-full">
 | 
			
		||||
      <legend class="fieldset-legend text-sm">选择或拖拽上传文件</legend>
 | 
			
		||||
      <legend class="fieldset-legend text-sm">上传自定义比特流文件</legend>
 | 
			
		||||
      <input type="file" ref="fileInput" class="file-input w-full" @change="handleFileChange" />
 | 
			
		||||
      <label class="fieldset-label">文件最大容量: {{ maxMemory }}MB</label>
 | 
			
		||||
    </fieldset>
 | 
			
		||||
 | 
			
		||||
    <!-- Upload Button -->
 | 
			
		||||
    <div class="card-actions w-full">
 | 
			
		||||
      <button @click="handleClick" class="btn btn-primary grow" :disabled="isUploading">
 | 
			
		||||
      <button @click="handleClick" class="btn btn-primary grow" :disabled="isUploading || isProgramming">
 | 
			
		||||
        <div v-if="isUploading">
 | 
			
		||||
          <span class="loading loading-spinner"></span>
 | 
			
		||||
          下载中...
 | 
			
		||||
          上传中...
 | 
			
		||||
        </div>
 | 
			
		||||
        <div v-else>
 | 
			
		||||
          {{ buttonText }}
 | 
			
		||||
@@ -27,6 +70,7 @@
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { computed, ref, useTemplateRef, onMounted } from "vue";
 | 
			
		||||
import { AuthManager } from "@/utils/AuthManager"; // Adjust the path based on your project structure
 | 
			
		||||
import { useDialogStore } from "@/stores/dialog";
 | 
			
		||||
import { isNull, isUndefined } from "lodash";
 | 
			
		||||
 | 
			
		||||
@@ -34,10 +78,12 @@ interface Props {
 | 
			
		||||
  uploadEvent?: (file: File) => Promise<boolean>;
 | 
			
		||||
  downloadEvent?: () => Promise<boolean>;
 | 
			
		||||
  maxMemory?: number;
 | 
			
		||||
  examId?: string; // 新增examId属性
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<Props>(), {
 | 
			
		||||
  maxMemory: 4,
 | 
			
		||||
  examId: '',
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emits = defineEmits<{
 | 
			
		||||
@@ -47,6 +93,10 @@ const emits = defineEmits<{
 | 
			
		||||
const dialog = useDialogStore();
 | 
			
		||||
 | 
			
		||||
const isUploading = ref(false);
 | 
			
		||||
const isDownloading = ref(false);
 | 
			
		||||
const isProgramming = ref(false);
 | 
			
		||||
const availableBitstreams = ref<{id: number, name: string}[]>([]);
 | 
			
		||||
 | 
			
		||||
const buttonText = computed(() => {
 | 
			
		||||
  return isUndefined(props.downloadEvent) ? "上传" : "上传并下载";
 | 
			
		||||
});
 | 
			
		||||
@@ -56,14 +106,113 @@ const bitstream = defineModel("bitstreamFile", {
 | 
			
		||||
  type: File,
 | 
			
		||||
  default: undefined,
 | 
			
		||||
});
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
 | 
			
		||||
// 初始化时加载示例比特流
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  if (!isUndefined(bitstream.value) && !isNull(fileInput.value)) {
 | 
			
		||||
    let fileList = new DataTransfer();
 | 
			
		||||
    fileList.items.add(bitstream.value);
 | 
			
		||||
    fileInput.value.files = fileList.files;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  await loadAvailableBitstreams();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// 加载可用的比特流文件列表
 | 
			
		||||
async function loadAvailableBitstreams() {
 | 
			
		||||
  console.log('加载可用比特流文件,examId:', props.examId);
 | 
			
		||||
  if (!props.examId) {
 | 
			
		||||
    availableBitstreams.value = [];
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    const examClient = AuthManager.createAuthenticatedExamClient();
 | 
			
		||||
    // 使用新的API获取比特流资源列表
 | 
			
		||||
    const resources = await examClient.getExamResourceList(props.examId, 'bitstream');
 | 
			
		||||
    availableBitstreams.value = resources.map(r => ({ id: r.id, name: r.name })) || [];
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('加载比特流列表失败:', error);
 | 
			
		||||
    availableBitstreams.value = [];
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 下载示例比特流
 | 
			
		||||
async function downloadExampleBitstream(bitstream: {id: number, name: string}) {
 | 
			
		||||
  if (isDownloading.value) return;
 | 
			
		||||
  
 | 
			
		||||
  isDownloading.value = true;
 | 
			
		||||
  try {
 | 
			
		||||
    const examClient = AuthManager.createAuthenticatedExamClient();
 | 
			
		||||
    
 | 
			
		||||
    // 使用动态API获取资源文件
 | 
			
		||||
    const response = await examClient.getExamResourceById(bitstream.id);
 | 
			
		||||
    
 | 
			
		||||
    if (response && response.data) {
 | 
			
		||||
      // 创建下载链接
 | 
			
		||||
      const url = URL.createObjectURL(response.data);
 | 
			
		||||
      const link = document.createElement('a');
 | 
			
		||||
      link.href = url;
 | 
			
		||||
      link.download = response.fileName || bitstream.name;
 | 
			
		||||
      document.body.appendChild(link);
 | 
			
		||||
      link.click();
 | 
			
		||||
      document.body.removeChild(link);
 | 
			
		||||
      URL.revokeObjectURL(url);
 | 
			
		||||
      
 | 
			
		||||
      dialog.info("示例比特流下载成功");
 | 
			
		||||
    } else {
 | 
			
		||||
      dialog.error("下载失败:响应数据为空");
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('下载示例比特流失败:', error);
 | 
			
		||||
    dialog.error("下载示例比特流失败");
 | 
			
		||||
  } finally {
 | 
			
		||||
    isDownloading.value = false;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 直接烧录示例比特流
 | 
			
		||||
async function programExampleBitstream(bitstream: {id: number, name: string}) {
 | 
			
		||||
  if (isProgramming.value || !props.uploadEvent) return;
 | 
			
		||||
  
 | 
			
		||||
  isProgramming.value = true;
 | 
			
		||||
  try {
 | 
			
		||||
    const examClient = AuthManager.createAuthenticatedExamClient();
 | 
			
		||||
    
 | 
			
		||||
    // 使用动态API获取比特流文件数据
 | 
			
		||||
    const response = await examClient.getExamResourceById(bitstream.id);
 | 
			
		||||
    
 | 
			
		||||
    if (!response || !response.data) {
 | 
			
		||||
      throw new Error('获取比特流文件失败');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const file = new File([response.data], response.fileName || bitstream.name, { type: response.data.type });
 | 
			
		||||
    
 | 
			
		||||
    // 调用上传事件
 | 
			
		||||
    const uploadSuccess = await props.uploadEvent(file);
 | 
			
		||||
    if (uploadSuccess) {
 | 
			
		||||
      // 如果有下载事件(烧录),则执行
 | 
			
		||||
      if (props.downloadEvent) {
 | 
			
		||||
        const downloadSuccess = await props.downloadEvent();
 | 
			
		||||
        if (downloadSuccess) {
 | 
			
		||||
          dialog.info("示例比特流烧录成功");
 | 
			
		||||
        } else {
 | 
			
		||||
          dialog.error("烧录失败");
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        dialog.info("示例比特流上传成功");
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      dialog.error("上传失败");
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('烧录示例比特流失败:', error);
 | 
			
		||||
    dialog.error("烧录示例比特流失败");
 | 
			
		||||
  } finally {
 | 
			
		||||
    isProgramming.value = false;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function handleFileChange(event: Event): void {
 | 
			
		||||
  const target = event.target as HTMLInputElement;
 | 
			
		||||
  const file = target.files?.[0]; // 获取选中的第一个文件
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@
 | 
			
		||||
    to="#ComponentCapabilities"
 | 
			
		||||
    v-if="selectecComponentID === props.componentId"
 | 
			
		||||
  >
 | 
			
		||||
    <MotherBoardCaps :jtagFreq="jtagFreq" @change-jtag-freq="changeJtagFreq" />
 | 
			
		||||
    <MotherBoardCaps :jtagFreq="jtagFreq" :exam-id="examId" @change-jtag-freq="changeJtagFreq" />
 | 
			
		||||
  </Teleport>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
@@ -41,6 +41,7 @@ import { toNumber } from "lodash";
 | 
			
		||||
export interface MotherBoardProps {
 | 
			
		||||
  size: number;
 | 
			
		||||
  componentId?: string;
 | 
			
		||||
  examId?: string; // 新增examId属性
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
 
 | 
			
		||||
@@ -16,6 +16,7 @@
 | 
			
		||||
    <div class="divider"></div>
 | 
			
		||||
    <UploadCard class="bg-base-200" :upload-event="eqps.jtagUploadBitstream"
 | 
			
		||||
      :download-event="eqps.jtagDownloadBitstream" :bitstream-file="eqps.jtagBitstream"
 | 
			
		||||
      :exam-id="examId"
 | 
			
		||||
      @update:bitstream-file="handleBitstreamChange">
 | 
			
		||||
    </UploadCard>
 | 
			
		||||
    <div class="divider"></div>
 | 
			
		||||
@@ -61,6 +62,7 @@ import { RefreshCcwIcon } from "lucide-vue-next";
 | 
			
		||||
 | 
			
		||||
interface CapsProps {
 | 
			
		||||
  jtagFreq?: string;
 | 
			
		||||
  examId?: string; // 新增examId属性
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const emits = defineEmits<{
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -25,26 +25,37 @@
 | 
			
		||||
          </h1>
 | 
			
		||||
 | 
			
		||||
          <p class="py-6 text-lg opacity-80 leading-relaxed">
 | 
			
		||||
            Prototype and simulate electronic circuits in your browser with our
 | 
			
		||||
            modern, intuitive interface. Create, test, and share your FPGA
 | 
			
		||||
            designs seamlessly.
 | 
			
		||||
            在浏览器中进行FPGA原型设计和电路仿真,使用现代直观的界面。创建、测试和分享您的FPGA设计,体验从基础学习到高级项目的完整开发流程。
 | 
			
		||||
          </p>
 | 
			
		||||
          <div class="flex flex-wrap gap-4 actions-container">
 | 
			
		||||
          <div class="flex flex-col sm:flex-row gap-4 actions-container">
 | 
			
		||||
            <router-link
 | 
			
		||||
              to="/project"
 | 
			
		||||
              class="btn btn-primary text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1"
 | 
			
		||||
              class="btn btn-primary text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1 flex-1 sm:flex-none"
 | 
			
		||||
            >
 | 
			
		||||
              <BookOpen class="h-5 w-5 mr-2" />
 | 
			
		||||
              进入工程界面
 | 
			
		||||
            </router-link>
 | 
			
		||||
            <router-link
 | 
			
		||||
              to="/exam"
 | 
			
		||||
              class="btn btn-secondary text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1 flex-1 sm:flex-none"
 | 
			
		||||
            >
 | 
			
		||||
              <GraduationCap class="h-5 w-5 mr-2" />
 | 
			
		||||
              实验列表
 | 
			
		||||
            </router-link>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div
 | 
			
		||||
            class="mt-8 p-4 bg-base-300 rounded-lg shadow-inner opacity-80 transition-all duration-300 hover:opacity-100 hover:shadow-md"
 | 
			
		||||
          >
 | 
			
		||||
            <p class="text-sm">
 | 
			
		||||
              <span class="font-semibold text-primary">提示:</span>
 | 
			
		||||
              您可以在工程界面中创建、编辑和测试您的FPGA项目,使用我们简洁直观的界面轻松进行硬件设计。
 | 
			
		||||
            </p>
 | 
			
		||||
            <div class="space-y-2">
 | 
			
		||||
              <p class="text-sm">
 | 
			
		||||
                <span class="font-semibold text-primary">工程界面:</span>
 | 
			
		||||
                自由创建和编辑FPGA项目,使用可视化画布进行电路设计和仿真测试。
 | 
			
		||||
              </p>
 | 
			
		||||
              <p class="text-sm">
 | 
			
		||||
                <span class="font-semibold text-secondary">实验列表:</span>
 | 
			
		||||
                浏览结构化的学习实验,从基础概念到高级应用的系统性学习路径。
 | 
			
		||||
              </p>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -55,7 +66,7 @@
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import "@/router";
 | 
			
		||||
import TutorialCarousel from "@/components/TutorialCarousel.vue";
 | 
			
		||||
import { BookOpen } from "lucide-vue-next";
 | 
			
		||||
import { BookOpen, GraduationCap } from "lucide-vue-next";
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped lang="postcss">
 | 
			
		||||
 
 | 
			
		||||
@@ -29,6 +29,7 @@
 | 
			
		||||
              <DiagramCanvas
 | 
			
		||||
                ref="diagramCanvas"
 | 
			
		||||
                :showDocPanel="showDocPanel"
 | 
			
		||||
                :exam-id="(route.query.examId as string) || ''"
 | 
			
		||||
                @open-components="openComponentsMenu"
 | 
			
		||||
                @toggle-doc-panel="toggleDocPanel"
 | 
			
		||||
              />
 | 
			
		||||
@@ -59,7 +60,10 @@
 | 
			
		||||
                  v-show="showDocPanel"
 | 
			
		||||
                  class="doc-panel overflow-y-auto h-full"
 | 
			
		||||
                >
 | 
			
		||||
                  <MarkdownRenderer :content="documentContent" />
 | 
			
		||||
                  <MarkdownRenderer 
 | 
			
		||||
                    :content="documentContent" 
 | 
			
		||||
                    :examId="(route.query.examId as string) || ''"
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </SplitterPanel>
 | 
			
		||||
@@ -115,7 +119,6 @@ import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
 | 
			
		||||
import BottomBar from "@/views/Project/BottomBar.vue";
 | 
			
		||||
import RequestBoardDialog from "@/views/Project/RequestBoardDialog.vue";
 | 
			
		||||
import { useProvideComponentManager } from "@/components/LabCanvas";
 | 
			
		||||
import type { DiagramData } from "@/components/LabCanvas";
 | 
			
		||||
import { useAlertStore } from "@/components/Alert";
 | 
			
		||||
import { AuthManager } from "@/utils/AuthManager";
 | 
			
		||||
import { useEquipments } from "@/stores/equipments";
 | 
			
		||||
@@ -182,29 +185,37 @@ async function toggleDocPanel() {
 | 
			
		||||
// 加载文档内容
 | 
			
		||||
async function loadDocumentContent() {
 | 
			
		||||
  try {
 | 
			
		||||
    // 从路由参数中获取教程ID
 | 
			
		||||
    const tutorialId = (route.query.tutorial as string) || "02"; // 默认加载02例程
 | 
			
		||||
 | 
			
		||||
    // 构建文档路径
 | 
			
		||||
    let docPath = `/doc/${tutorialId}/doc.md`;
 | 
			
		||||
 | 
			
		||||
    // 检查当前路径是否包含下划线(例如 02_key 格式)
 | 
			
		||||
    // 如果不包含,那么使用更新的命名格式
 | 
			
		||||
    if (!tutorialId.includes("_")) {
 | 
			
		||||
      docPath = `/doc/${tutorialId}/doc.md`;
 | 
			
		||||
    // 检查是否有实验ID参数
 | 
			
		||||
    const examId = route.query.examId as string;
 | 
			
		||||
    if (examId) {
 | 
			
		||||
      // 如果有实验ID,从API加载实验文档
 | 
			
		||||
      console.log('加载实验文档:', examId);
 | 
			
		||||
      const client = AuthManager.createAuthenticatedExamClient();
 | 
			
		||||
      
 | 
			
		||||
      // 获取markdown类型的资源列表
 | 
			
		||||
      const resources = await client.getExamResourceList(examId, 'doc');
 | 
			
		||||
      
 | 
			
		||||
      if (resources && resources.length > 0) {
 | 
			
		||||
        // 获取第一个markdown资源
 | 
			
		||||
        const markdownResource = resources[0];
 | 
			
		||||
        
 | 
			
		||||
        // 使用动态API获取资源文件内容
 | 
			
		||||
        const response = await client.getExamResourceById(markdownResource.id);
 | 
			
		||||
        
 | 
			
		||||
        if (!response || !response.data) {
 | 
			
		||||
          throw new Error('获取markdown文件失败');
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const content = await response.data.text();
 | 
			
		||||
        
 | 
			
		||||
        // 更新文档内容,暂时不处理图片路径,由MarkdownRenderer处理
 | 
			
		||||
        documentContent.value = content;
 | 
			
		||||
      } else {
 | 
			
		||||
        documentContent.value = "# 暂无实验文档\n\n该实验尚未提供文档内容。";
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      documentContent.value = "# 无文档";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 获取文档内容
 | 
			
		||||
    const response = await fetch(docPath);
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      throw new Error(`Failed to load document: ${response.status}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 更新文档内容,并替换图片路径
 | 
			
		||||
    documentContent.value = (await response.text()).replace(
 | 
			
		||||
      /.\/images/gi,
 | 
			
		||||
      `/doc/${tutorialId}/images`,
 | 
			
		||||
    );
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("加载文档失败:", error);
 | 
			
		||||
    documentContent.value = "# 文档加载失败\n\n无法加载请求的文档。";
 | 
			
		||||
@@ -312,8 +323,8 @@ onMounted(async () => {
 | 
			
		||||
  // 检查并初始化用户实验板
 | 
			
		||||
  await checkAndInitializeBoard();
 | 
			
		||||
 | 
			
		||||
  // 检查是否有例程参数,如果有则自动打开文档面板
 | 
			
		||||
  if (route.query.tutorial) {
 | 
			
		||||
  // 检查是否有例程参数或实验ID参数,如果有则自动打开文档面板
 | 
			
		||||
  if (route.query.tutorial || route.query.examId) {
 | 
			
		||||
    showDocPanel.value = true;
 | 
			
		||||
    await loadDocumentContent();
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user