12 Commits

35 changed files with 4432 additions and 1955 deletions

View File

@@ -303,8 +303,11 @@ async function generateApiClient(): Promise<void> {
async function generateSignalRClient(): Promise<void> {
console.log("Generating SignalR TypeScript client...");
try {
// TypedSignalR.Client.TypeScript.Analyzer 会在编译时自动生成客户端
// 我们只需要确保服务器项目构建一次即可生成 TypeScript 客户端
const { stdout, stderr } = await execAsync(
"dotnet tsrts --project ./server/server.csproj --output ./src",
"dotnet build --configuration Release",
{ cwd: "./server" }
);
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);

View File

@@ -95,6 +95,12 @@ try
.AllowAnyMethod()
.AllowAnyHeader()
);
options.AddPolicy("SignalR", policy => policy
.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
);
});
// Use SignalR
@@ -171,6 +177,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");
}
@@ -190,29 +207,19 @@ try
};
});
app.UseSwaggerUi();
// SignalR
app.UseWebSockets();
app.UseSignalRHubSpecification();
app.UseSignalRHubDevelopmentUI();
// Router
app.MapControllers();
app.MapHub<server.Hubs.JtagHub.JtagHub>("hubs/JtagHub").RequireCors("Users");
app.MapHub<server.Hubs.JtagHub.JtagHub>("hubs/JtagHub");
// 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) =>
{

View File

@@ -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 开发工具进行仿真
## 注意事项
- 确保输入信号的电平正确
- 注意时序的约束
- 验证结果时要仔细对比真值表

View File

@@ -1,35 +0,0 @@
# 实验002组合逻辑电路设计
## 实验目的
本实验旨在让学生学习如何设计和实现复杂的组合逻辑电路,掌握多个逻辑门的组合使用。
## 实验内容
### 1. 半加器设计
设计一个半加器电路,实现两个一位二进制数的加法运算。
### 2. 全加器设计
在半加器的基础上,设计全加器电路,考虑进位输入。
### 3. 编码器和译码器
实现简单的编码器和译码器电路。
## 实验要求
1. 使用 Verilog HDL 编写代码
2. 绘制逻辑电路图
3. 编写测试用例验证功能
4. 分析电路的延时特性
## 评估标准
- 电路功能正确性 (40%)
- 代码质量和规范性 (30%)
- 测试覆盖率 (20%)
- 实验报告 (10%)
## 参考资料
- 数字逻辑设计教材第3-4章
- Verilog HDL 语法参考手册

View File

@@ -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,55 @@ 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 CreateExamRequest
{
/// <summary>
/// 结果消息
/// 实验ID
/// </summary>
public required string Message { get; set; }
public required string ID { get; set; }
/// <summary>
/// 更新的实验数量
/// 实验名称
/// </summary>
public int UpdateCount { get; set; }
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 +157,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 +214,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 +234,58 @@ 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)
{
if (string.IsNullOrEmpty(markdownContent))
return "";
var lines = markdownContent.Split('\n');
foreach (var line in lines)
{
var trimmedLine = line.Trim();
if (trimmedLine.StartsWith("# "))
{
return trimmedLine.Substring(2).Trim();
}
}
return "";
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Database;
namespace server.Controllers;
@@ -14,8 +15,6 @@ public class JtagController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private const string BITSTREAM_PATH = "bitstream/Jtag";
/// <summary>
/// 控制器首页信息
/// </summary>
@@ -112,64 +111,12 @@ public class JtagController : ControllerBase
}
}
/// <summary>
/// 上传比特流文件到服务器
/// </summary>
/// <param name="address">目标设备地址</param>
/// <param name="file">比特流文件</param>
/// <returns>上传结果</returns>
[HttpPost("UploadBitstream")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async ValueTask<IResult> UploadBitstream(string address, IFormFile file)
{
logger.Info($"User {User.Identity?.Name} uploading bitstream for device {address}");
if (file == null || file.Length == 0)
{
logger.Warn($"User {User.Identity?.Name} attempted to upload empty file for device {address}");
return TypedResults.BadRequest("未选择文件");
}
try
{
// 生成安全的文件名(避免路径遍历攻击)
var fileName = Path.GetRandomFileName();
var uploadsFolder = Path.Combine(Environment.CurrentDirectory, $"{BITSTREAM_PATH}/{address}");
// 如果存在文件,则删除原文件再上传
if (Directory.Exists(uploadsFolder))
{
Directory.Delete(uploadsFolder, true);
logger.Info($"User {User.Identity?.Name} removed existing bitstream folder for device {address}");
}
Directory.CreateDirectory(uploadsFolder);
var filePath = Path.Combine(uploadsFolder, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
logger.Info($"User {User.Identity?.Name} successfully uploaded bitstream for device {address}, file size: {file.Length} bytes");
return TypedResults.Ok(true);
}
catch (Exception ex)
{
logger.Error(ex, $"User {User.Identity?.Name} failed to upload bitstream for device {address}");
return TypedResults.InternalServerError(ex);
}
}
/// <summary>
/// 通过 JTAG 下载比特流文件到 FPGA 设备
/// </summary>
/// <param name="address">JTAG 设备地址</param>
/// <param name="port">JTAG 设备端口</param>
/// <param name="bitstreamId">比特流ID</param>
/// <returns>下载结果</returns>
[HttpPost("DownloadBitstream")]
[EnableCors("Users")]
@@ -177,87 +124,111 @@ public class JtagController : ControllerBase
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async ValueTask<IResult> DownloadBitstream(string address, int port)
public async ValueTask<IResult> DownloadBitstream(string address, int port, int bitstreamId)
{
logger.Info($"User {User.Identity?.Name} initiating bitstream download to device {address}:{port}");
// 检查文件
var fileDir = Path.Combine(Environment.CurrentDirectory, $"{BITSTREAM_PATH}/{address}");
if (!Directory.Exists(fileDir))
{
logger.Warn($"User {User.Identity?.Name} attempted to download non-existent bitstream for device {address}");
return TypedResults.BadRequest("Empty bitstream, Please upload it first");
}
logger.Info($"User {User.Identity?.Name} initiating bitstream download to device {address}:{port} using bitstream ID: {bitstreamId}");
try
{
// 读取文件
var filePath = Directory.GetFiles(fileDir)[0];
logger.Info($"User {User.Identity?.Name} reading bitstream file: {filePath}");
using (var fileStream = System.IO.File.Open(filePath, System.IO.FileMode.Open))
// 获取当前用户名
var username = User.Identity?.Name;
if (string.IsNullOrEmpty(username))
{
if (fileStream is null || fileStream.Length <= 0)
logger.Warn("Anonymous user attempted to download bitstream");
return TypedResults.Unauthorized();
}
// 从数据库获取用户信息
using var db = new Database.AppDataConnection();
var userResult = db.GetUserByName(username);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
{
logger.Error($"User {username} not found in database");
return TypedResults.BadRequest("用户不存在");
}
var user = userResult.Value.Value;
// 从数据库获取比特流
var bitstreamResult = db.GetResourceById(bitstreamId);
if (!bitstreamResult.IsSuccessful)
{
logger.Error($"User {username} failed to get bitstream from database: {bitstreamResult.Error}");
return TypedResults.InternalServerError($"数据库查询失败: {bitstreamResult.Error?.Message}");
}
if (!bitstreamResult.Value.HasValue)
{
logger.Warn($"User {username} attempted to download non-existent bitstream ID: {bitstreamId}");
return TypedResults.BadRequest("比特流不存在");
}
var bitstream = bitstreamResult.Value.Value;
// 处理比特流数据
var fileBytes = bitstream.Data;
if (fileBytes == null || fileBytes.Length == 0)
{
logger.Warn($"User {username} found empty bitstream data for ID: {bitstreamId}");
return TypedResults.BadRequest("比特流数据为空,请重新上传");
}
logger.Info($"User {username} processing bitstream file of size: {fileBytes.Length} bytes");
// 定义缓冲区大小: 32KB
byte[] buffer = new byte[32 * 1024];
byte[] revBuffer = new byte[32 * 1024];
long totalBytesProcessed = 0;
// 使用内存流处理文件
using (var inputStream = new MemoryStream(fileBytes))
using (var outputStream = new MemoryStream())
{
int bytesRead;
while ((bytesRead = await inputStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
logger.Warn($"User {User.Identity?.Name} found invalid bitstream file for device {address}");
return TypedResults.BadRequest("Wrong bitstream, Please upload it again");
// 反转 32bits
var retBuffer = Common.Number.ReverseBytes(buffer, 4);
if (!retBuffer.IsSuccessful)
{
logger.Error($"User {username} failed to reverse bytes: {retBuffer.Error}");
return TypedResults.InternalServerError(retBuffer.Error);
}
revBuffer = retBuffer.Value;
for (int i = 0; i < revBuffer.Length; i++)
{
revBuffer[i] = Common.Number.ReverseBits(revBuffer[i]);
}
await outputStream.WriteAsync(revBuffer, 0, bytesRead);
totalBytesProcessed += bytesRead;
}
logger.Info($"User {User.Identity?.Name} processing bitstream file of size: {fileStream.Length} bytes");
// 获取处理后的数据
var processedBytes = outputStream.ToArray();
logger.Info($"User {username} processed {totalBytesProcessed} bytes for device {address}");
// 定义缓冲区大小: 32KB
byte[] buffer = new byte[32 * 1024];
byte[] revBuffer = new byte[32 * 1024];
long totalBytesRead = 0;
// 下载比特流
var jtagCtrl = new Peripherals.JtagClient.Jtag(address, port);
var ret = await jtagCtrl.DownloadBitstream(processedBytes);
// 使用异步流读取文件
using (var memoryStream = new MemoryStream())
if (ret.IsSuccessful)
{
int bytesRead;
while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
// 反转 32bits
var retBuffer = Common.Number.ReverseBytes(buffer, 4);
if (!retBuffer.IsSuccessful)
{
logger.Error($"User {User.Identity?.Name} failed to reverse bytes: {retBuffer.Error}");
return TypedResults.InternalServerError(retBuffer.Error);
}
revBuffer = retBuffer.Value;
for (int i = 0; i < revBuffer.Length; i++)
{
revBuffer[i] = Common.Number.ReverseBits(revBuffer[i]);
}
await memoryStream.WriteAsync(revBuffer, 0, bytesRead);
totalBytesRead += bytesRead;
}
// 将所有数据转换为字节数组(注意:如果文件非常大,可能不适合完全加载到内存)
var fileBytes = memoryStream.ToArray();
logger.Info($"User {User.Identity?.Name} processed {totalBytesRead} bytes for device {address}");
// 下载比特流
var jtagCtrl = new Peripherals.JtagClient.Jtag(address, port);
var ret = await jtagCtrl.DownloadBitstream(fileBytes);
if (ret.IsSuccessful)
{
logger.Info($"User {User.Identity?.Name} successfully downloaded bitstream to device {address}");
return TypedResults.Ok(ret.Value);
}
else
{
logger.Error($"User {User.Identity?.Name} failed to download bitstream to device {address}: {ret.Error}");
return TypedResults.InternalServerError(ret.Error);
}
logger.Info($"User {username} successfully downloaded bitstream '{bitstream.ResourceName}' to device {address}");
return TypedResults.Ok(ret.Value);
}
else
{
logger.Error($"User {username} failed to download bitstream to device {address}: {ret.Error}");
return TypedResults.InternalServerError(ret.Error);
}
}
}
catch (Exception ex)
{
logger.Error(ex, $"User {User.Identity?.Name} encountered exception while downloading bitstream to device {address}");
logger.Error(ex, $"User encountered exception while downloading bitstream to device {address}");
return TypedResults.InternalServerError(ex);
}
}

View File

@@ -0,0 +1,377 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using DotNext;
using Database;
namespace server.Controllers;
/// <summary>
/// 资源控制器 - 提供统一的资源管理API
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class ResourceController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
/// <summary>
/// 资源信息类
/// </summary>
public class ResourceInfo
{
/// <summary>
/// 资源ID
/// </summary>
public int ID { get; set; }
/// <summary>
/// 资源名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 资源类型
/// </summary>
public required string Type { get; set; }
/// <summary>
/// 资源用途template/user
/// </summary>
public required string Purpose { get; set; }
/// <summary>
/// 上传时间
/// </summary>
public DateTime UploadTime { get; set; }
/// <summary>
/// 所属实验ID可选
/// </summary>
public string? ExamID { get; set; }
/// <summary>
/// MIME类型
/// </summary>
public string? MimeType { get; set; }
}
/// <summary>
/// 添加资源请求类
/// </summary>
public class AddResourceRequest
{
/// <summary>
/// 资源类型
/// </summary>
public required string ResourceType { get; set; }
/// <summary>
/// 资源用途template/user
/// </summary>
public required string ResourcePurpose { get; set; }
/// <summary>
/// 所属实验ID可选
/// </summary>
public string? ExamID { get; set; }
}
/// <summary>
/// 添加资源(文件上传)
/// </summary>
/// <param name="request">添加资源请求</param>
/// <param name="file">资源文件</param>
/// <returns>添加结果</returns>
[Authorize]
[HttpPost]
[EnableCors("Users")]
[ProducesResponseType(typeof(ResourceInfo), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AddResource([FromForm] AddResourceRequest request, IFormFile file)
{
if (string.IsNullOrWhiteSpace(request.ResourceType) || string.IsNullOrWhiteSpace(request.ResourcePurpose) || file == null)
return BadRequest("资源类型、资源用途和文件不能为空");
// 验证资源用途
if (request.ResourcePurpose != Resource.ResourcePurposes.Template && request.ResourcePurpose != Resource.ResourcePurposes.User)
return BadRequest($"无效的资源用途: {request.ResourcePurpose}");
// 模板资源需要管理员权限
if (request.ResourcePurpose == Resource.ResourcePurposes.Template && !User.IsInRole("Admin"))
return Forbid("只有管理员可以添加模板资源");
try
{
using var db = new Database.AppDataConnection();
// 获取当前用户ID
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = db.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 读取文件数据
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
var fileData = memoryStream.ToArray();
var result = db.AddResource(user.ID, request.ResourceType, request.ResourcePurpose, file.FileName, fileData, request.ExamID);
if (!result.IsSuccessful)
{
if (result.Error.Message.Contains("不存在"))
return NotFound(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,
Type = resource.ResourceType,
Purpose = resource.ResourcePurpose,
UploadTime = resource.UploadTime,
ExamID = resource.ExamID,
MimeType = resource.MimeType
};
logger.Info($"成功添加资源: {request.ResourceType}/{request.ResourcePurpose}/{file.FileName}");
return CreatedAtAction(nameof(GetResourceById), new { resourceId = resource.ID }, resourceInfo);
}
catch (Exception ex)
{
logger.Error($"添加资源 {request.ResourceType}/{request.ResourcePurpose}/{file.FileName} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"添加资源失败: {ex.Message}");
}
}
/// <summary>
/// 获取资源列表
/// </summary>
/// <param name="examId">实验ID可选</param>
/// <param name="resourceType">资源类型(可选)</param>
/// <param name="resourcePurpose">资源用途(可选)</param>
/// <returns>资源列表</returns>
[Authorize]
[HttpGet]
[EnableCors("Users")]
[ProducesResponseType(typeof(ResourceInfo[]), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetResourceList([FromQuery] string? examId = null, [FromQuery] string? resourceType = null, [FromQuery] string? resourcePurpose = null)
{
try
{
using var db = new Database.AppDataConnection();
// 获取当前用户ID
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = db.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 普通用户只能查看自己的资源和模板资源
Guid? userId = null;
if (!User.IsInRole("Admin"))
{
// 如果指定了用户资源用途,则只查看自己的资源
if (resourcePurpose == Resource.ResourcePurposes.User)
{
userId = user.ID;
}
// 如果指定了模板资源用途则不限制用户ID
else if (resourcePurpose == Resource.ResourcePurposes.Template)
{
userId = null;
}
// 如果没有指定用途,则查看自己的用户资源和所有模板资源
else
{
// 这种情况下需要分别查询并合并结果
var userResourcesResult = db.GetFullResourceList(examId, resourceType, Resource.ResourcePurposes.User, user.ID);
var templateResourcesResult = db.GetFullResourceList(examId, resourceType, Resource.ResourcePurposes.Template, null);
if (!userResourcesResult.IsSuccessful || !templateResourcesResult.IsSuccessful)
{
logger.Error($"获取资源列表时出错");
return StatusCode(StatusCodes.Status500InternalServerError, "获取资源列表失败");
}
var allResources = userResourcesResult.Value.Concat(templateResourcesResult.Value)
.OrderByDescending(r => r.UploadTime);
var mergedResourceInfos = allResources.Select(r => new ResourceInfo
{
ID = r.ID,
Name = r.ResourceName,
Type = r.ResourceType,
Purpose = r.ResourcePurpose,
UploadTime = r.UploadTime,
ExamID = r.ExamID,
MimeType = r.MimeType
}).ToArray();
logger.Info($"成功获取资源列表,共 {mergedResourceInfos.Length} 个资源");
return Ok(mergedResourceInfos);
}
}
var result = db.GetFullResourceList(examId, resourceType, resourcePurpose, userId);
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.ResourceName,
Type = r.ResourceType,
Purpose = r.ResourcePurpose,
UploadTime = r.UploadTime,
ExamID = r.ExamID,
MimeType = r.MimeType
}).ToArray();
logger.Info($"成功获取资源列表,共 {resources.Length} 个资源");
return Ok(resources);
}
catch (Exception ex)
{
logger.Error($"获取资源列表时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源列表失败: {ex.Message}");
}
}
/// <summary>
/// 根据资源ID下载资源
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>资源文件</returns>
[HttpGet("{resourceId}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetResourceById(int resourceId)
{
try
{
using var db = new Database.AppDataConnection();
var result = db.GetResourceById(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 ?? "application/octet-stream", resource.ResourceName);
}
catch (Exception ex)
{
logger.Error($"获取资源 {resourceId} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {ex.Message}");
}
}
/// <summary>
/// 删除资源
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>删除结果</returns>
[Authorize]
[HttpDelete("{resourceId}")]
[EnableCors("Users")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult DeleteResource(int resourceId)
{
try
{
using var db = new Database.AppDataConnection();
// 获取当前用户信息
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = db.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 先获取资源信息以验证权限
var resourceResult = db.GetResourceById(resourceId);
if (!resourceResult.IsSuccessful)
{
logger.Error($"获取资源时出错: {resourceResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {resourceResult.Error.Message}");
}
if (!resourceResult.Value.HasValue)
{
logger.Warn($"资源不存在: {resourceId}");
return NotFound($"资源 {resourceId} 不存在");
}
var resource = resourceResult.Value.Value;
// 权限检查:管理员可以删除所有资源,普通用户只能删除自己的用户资源
if (!User.IsInRole("Admin"))
{
if (resource.ResourcePurpose == Resource.ResourcePurposes.Template)
return Forbid("普通用户不能删除模板资源");
if (resource.UserID != user.ID)
return Forbid("只能删除自己的资源");
}
var deleteResult = db.DeleteResource(resourceId);
if (!deleteResult.IsSuccessful)
{
logger.Error($"删除资源时出错: {deleteResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"删除资源失败: {deleteResult.Error.Message}");
}
logger.Info($"成功删除资源: {resourceId} ({resource.ResourceName})");
return NoContent();
}
catch (Exception ex)
{
logger.Error($"删除资源 {resourceId} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"删除资源失败: {ex.Message}");
}
}
}

View File

@@ -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,155 @@ public class Exam
/// </summary>
[NotNull]
public DateTime UpdatedTime { get; set; } = DateTime.Now;
/// <summary>
/// 实验标签(以逗号分隔的字符串)
/// </summary>
[NotNull]
public string Tags { get; set; } = "";
/// <summary>
/// 实验难度1-51为最简单
/// </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 Resource
{
/// <summary>
/// 资源的唯一标识符
/// </summary>
[PrimaryKey, Identity]
public int ID { get; set; }
/// <summary>
/// 上传资源的用户ID
/// </summary>
[NotNull]
public required Guid UserID { get; set; }
/// <summary>
/// 所属实验ID可选如果不属于特定实验则为空
/// </summary>
[Nullable]
public string? ExamID { get; set; }
/// <summary>
/// 资源类型images, markdown, bitstream, diagram, project等
/// </summary>
[NotNull]
public required string ResourceType { get; set; }
/// <summary>
/// 资源用途template模板或 user用户上传
/// </summary>
[NotNull]
public required string ResourcePurpose { 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 UploadTime { 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>
/// 资源用途枚举
/// </summary>
public static class ResourcePurposes
{
/// <summary>
/// 模板资源,通常由管理员上传,供用户参考
/// </summary>
public const string Template = "template";
/// <summary>
/// 用户上传的资源
/// </summary>
public const string User = "user";
}
}
/// <summary>
@@ -228,6 +383,7 @@ public class AppDataConnection : DataConnection
this.CreateTable<User>();
this.CreateTable<Board>();
this.CreateTable<Exam>();
this.CreateTable<Resource>();
logger.Info("数据库表创建完成");
}
@@ -240,6 +396,7 @@ public class AppDataConnection : DataConnection
this.DropTable<User>();
this.DropTable<Board>();
this.DropTable<Exam>();
this.DropTable<Resource>();
logger.Warn("所有数据库表已删除");
}
@@ -699,75 +856,358 @@ 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<Resource> ResourceTable => this.GetTable<Resource>();
/// <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;
}
int updateCount = 0;
var subdirectories = Directory.GetDirectories(examFolderPath);
foreach (var examDir in subdirectories)
{
var examId = Path.GetFileName(examDir);
var docPath = Path.Combine(examDir, "doc.md");
if (!File.Exists(docPath))
// 检查实验ID是否已存在
var existingExam = this.ExamTable.Where(e => e.ID == id).FirstOrDefault();
if (existingExam != null)
{
logger.Warn($"实验 {examId} 缺少 doc.md 文件");
continue;
logger.Error($"实验ID已存在: {id}");
return new(new Exception($"实验ID已存在: {id}"));
}
try
var exam = new Exam
{
var docContent = File.ReadAllText(docPath);
var existingExam = this.ExamTable.Where(e => e.ID == examId).FirstOrDefault();
ID = id,
Name = name,
Description = description,
Difficulty = Math.Max(1, Math.Min(5, difficulty)),
IsVisibleToUsers = isVisibleToUsers,
CreatedTime = DateTime.Now,
UpdatedTime = DateTime.Now
};
if (existingExam == null)
if (tags != null)
{
exam.SetTagsList(tags);
}
this.Insert(exam);
logger.Info($"新实验已创建: {id} ({name})");
return new(exam);
}
catch (Exception ex)
{
logger.Error($"创建实验时出错: {ex.Message}");
return new(ex);
}
}
/// <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="userId">上传用户ID</param>
/// <param name="resourceType">资源类型</param>
/// <param name="resourcePurpose">资源用途template 或 user</param>
/// <param name="resourceName">资源名称</param>
/// <param name="data">资源二进制数据</param>
/// <param name="examId">所属实验ID可选</param>
/// <param name="mimeType">MIME类型可选将根据文件扩展名自动确定</param>
/// <returns>创建的资源</returns>
public Result<Resource> AddResource(Guid userId, string resourceType, string resourcePurpose, string resourceName, byte[] data, string? examId = null, string? mimeType = null)
{
try
{
// 验证用户是否存在
var user = this.UserTable.Where(u => u.ID == userId).FirstOrDefault();
if (user == null)
{
logger.Error($"用户不存在: {userId}");
return new(new Exception($"用户不存在: {userId}"));
}
// 如果指定了实验ID验证实验是否存在
if (!string.IsNullOrEmpty(examId))
{
var exam = this.ExamTable.Where(e => e.ID == examId).FirstOrDefault();
if (exam == 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++;
}
logger.Error($"实验不存在: {examId}");
return new(new Exception($"实验不存在: {examId}"));
}
}
catch (Exception ex)
// 验证资源用途
if (resourcePurpose != Resource.ResourcePurposes.Template && resourcePurpose != Resource.ResourcePurposes.User)
{
logger.Error($"处理实验 {examId} 时出错: {ex.Message}");
logger.Error($"无效的资源用途: {resourcePurpose}");
return new(new Exception($"无效的资源用途: {resourcePurpose}"));
}
// 如果未指定MIME类型根据文件扩展名自动确定
if (string.IsNullOrEmpty(mimeType))
{
var extension = Path.GetExtension(resourceName).ToLowerInvariant();
mimeType = GetMimeTypeFromExtension(extension, resourceName);
}
var resource = new Resource
{
UserID = userId,
ExamID = examId,
ResourceType = resourceType,
ResourcePurpose = resourcePurpose,
ResourceName = resourceName,
Data = data,
MimeType = mimeType,
UploadTime = DateTime.Now
};
var insertedId = this.InsertWithIdentity(resource);
resource.ID = Convert.ToInt32(insertedId);
logger.Info($"新资源已添加: {userId}/{resourceType}/{resourcePurpose}/{resourceName} ({data.Length} bytes)" +
(examId != null ? $" [实验: {examId}]" : "") + $" [ID: {resource.ID}]");
return new(resource);
}
catch (Exception ex)
{
logger.Error($"添加资源时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 获取资源信息列表返回ID和名称
/// <param name="resourceType">资源类型</param>
/// <param name="examId">实验ID可选</param>
/// <param name="resourcePurpose">资源用途(可选)</param>
/// <param name="userId">用户ID可选</param>
/// </summary>
/// <returns>资源信息列表</returns>
public Result<(int ID, string Name)[]> GetResourceList(string resourceType, string? examId = null, string? resourcePurpose = null, Guid? userId = null)
{
try
{
var query = this.ResourceTable.Where(r => r.ResourceType == resourceType);
if (examId != null)
{
query = query.Where(r => r.ExamID == examId);
}
if (resourcePurpose != null)
{
query = query.Where(r => r.ResourcePurpose == resourcePurpose);
}
if (userId != null)
{
query = query.Where(r => r.UserID == userId);
}
var resources = query
.Select(r => new { r.ID, r.ResourceName })
.ToArray();
var result = resources.Select(r => (r.ID, r.ResourceName)).ToArray();
logger.Info($"获取资源列表: {resourceType}" +
(examId != null ? $"/{examId}" : "") +
(resourcePurpose != null ? $"/{resourcePurpose}" : "") +
(userId != null ? $"/{userId}" : "") +
$",共 {result.Length} 个资源");
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="resourcePurpose">资源用途(可选)</param>
/// <param name="userId">用户ID可选</param>
/// <returns>完整的资源对象列表</returns>
public Result<List<Resource>> GetFullResourceList(string? examId = null, string? resourceType = null, string? resourcePurpose = null, Guid? userId = null)
{
try
{
var query = this.ResourceTable.AsQueryable();
if (examId != null)
{
query = query.Where(r => r.ExamID == examId);
}
if (resourceType != null)
{
query = query.Where(r => r.ResourceType == resourceType);
}
if (resourcePurpose != null)
{
query = query.Where(r => r.ResourcePurpose == resourcePurpose);
}
if (userId != null)
{
query = query.Where(r => r.UserID == userId);
}
var resources = query.OrderByDescending(r => r.UploadTime).ToList();
logger.Info($"获取完整资源列表" +
(examId != null ? $" [实验: {examId}]" : "") +
(resourceType != null ? $" [类型: {resourceType}]" : "") +
(resourcePurpose != null ? $" [用途: {resourcePurpose}]" : "") +
(userId != null ? $" [用户: {userId}]" : "") +
$",共 {resources.Count} 个资源");
return new(resources);
}
catch (Exception ex)
{
logger.Error($"获取完整资源列表时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 根据资源ID获取资源
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>资源数据</returns>
public Result<Optional<Resource>> GetResourceById(int resourceId)
{
try
{
var resource = this.ResourceTable.Where(r => r.ID == resourceId).FirstOrDefault();
if (resource == null)
{
logger.Info($"未找到资源: {resourceId}");
return new(Optional<Resource>.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> DeleteResource(int resourceId)
{
try
{
var result = this.ResourceTable.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";
}
logger.Info($"实验扫描完成,共更新 {updateCount} 个实验");
return updateCount;
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>
@@ -805,4 +1245,22 @@ public class AppDataConnection : DataConnection
logger.Debug($"成功获取实验信息: {examId}");
return new(exams[0]);
}
/// <summary>
/// 根据文件扩展名获取比特流MIME类型
/// </summary>
/// <param name="extension">文件扩展名</param>
/// <returns>MIME类型</returns>
private string GetBitstreamMimeType(string extension)
{
return extension.ToLowerInvariant() switch
{
".bit" => "application/octet-stream",
".sbit" => "application/octet-stream",
".bin" => "application/octet-stream",
".mcs" => "application/octet-stream",
".hex" => "text/plain",
_ => "application/octet-stream"
};
}
}

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using DotNext;
@@ -23,11 +25,19 @@ public interface IJtagReceiver
}
[Authorize]
[EnableCors("SignalR")]
public class JtagHub : Hub<IJtagReceiver>, IJtagHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private ConcurrentDictionary<string, int> FreqTable = new();
private ConcurrentDictionary<string, CancellationTokenSource> CancellationTokenSourceTable = new();
private static ConcurrentDictionary<string, int> FreqTable = new();
private static ConcurrentDictionary<string, CancellationTokenSource> CancellationTokenSourceTable = new();
private readonly IHubContext<JtagHub, IJtagReceiver> _hubContext;
public JtagHub(IHubContext<JtagHub, IJtagReceiver> hubContext)
{
_hubContext = hubContext;
}
private Optional<Peripherals.JtagClient.Jtag> GetJtagClient(string userName)
{
@@ -89,33 +99,40 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
}
await SetBoundaryScanFreq(freq);
var cts = CancellationTokenSource.CreateLinkedTokenSource();
var cts = new CancellationTokenSource();
CancellationTokenSourceTable.AddOrUpdate(userName, cts, (key, value) => cts);
_ = Task
.Run(
() => BoundaryScanLogicPorts(
Context.ConnectionId,
userName,
cts.Token),
_ = Task.Run(
() => BoundaryScanLogicPorts(Context.ConnectionId, userName, cts.Token),
cts.Token)
.ContinueWith((task) =>
{
if (!task.IsFaulted)
if (task.IsFaulted)
{
return;
// 遍历所有异常
foreach (var ex in task.Exception.InnerExceptions)
{
if (ex is OperationCanceledException)
{
logger.Info($"Boundary scan operation cancelled for user {userName}");
}
else
{
logger.Error($"Boundary scan operation failed for user {userName}: {ex}");
}
}
}
if (task.Exception.InnerException is OperationCanceledException)
else if (task.IsCanceled)
{
logger.Info($"Boundary scan operation cancelled for user {userName}");
}
else
{
logger.Error(task.Exception);
logger.Info($"Boundary scan completed successfully for user {userName}");
}
});
logger.Info($"Boundary scan started for user {userName}");
return true;
}
catch (Exception error)
@@ -141,10 +158,12 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
cts.Cancel();
cts.Token.WaitHandle.WaitOne();
logger.Info($"Boundary scan stopped for user {userName}");
return true;
}
private async void BoundaryScanLogicPorts(string connectionID, string userName, CancellationToken cancellationToken)
private async Task BoundaryScanLogicPorts(string connectionID, string userName, CancellationToken cancellationToken)
{
var jtagCtrl = GetJtagClient(userName).OrThrow(() => new InvalidOperationException("JTAG client not found"));
var cntFail = 0;
@@ -158,9 +177,10 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
{
logger.Error($"User {userName} boundary scan failed for device {jtagCtrl.address}: {ret.Error}");
cntFail++;
continue;
}
await this.Clients.Client(connectionID).OnReceiveBoundaryScanData(ret.Value);
await _hubContext.Clients.Client(connectionID).OnReceiveBoundaryScanData(ret.Value);
// logger.Info($"User {userName} successfully completed boundary scan for device {jtagCtrl.address}");
await Task.Delay(FreqTable.TryGetValue(userName, out var freq) ? 1000 / freq : 1000 / 100, cancellationToken);

View File

@@ -1,6 +1,7 @@
using System.Net;
using DotNext;
using Peripherals.PowerClient;
using WebProtocol;
namespace Peripherals.CameraClient;
@@ -19,7 +20,7 @@ class Camera
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 2000;
readonly int timeout = 500;
readonly int taskID;
readonly int port;
readonly string address;
@@ -43,7 +44,7 @@ class Camera
/// <param name="address">摄像头设备IP地址</param>
/// <param name="port">摄像头设备端口</param>
/// <param name="timeout">超时时间(毫秒)</param>
public Camera(string address, int port, int timeout = 2000)
public Camera(string address, int port, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
@@ -225,6 +226,7 @@ class Camera
this.taskID, // taskID
FrameAddr,
(int)_currentFrameLength, // 使用当前分辨率的动态大小
BurstType.ExtendBurst,
this.timeout);
if (!result.IsSuccessful)
@@ -462,6 +464,20 @@ class Camera
);
}
/// <summary>
/// 配置为960x540分辨率
/// </summary>
/// <returns>配置结果</returns>
public async ValueTask<Result<bool>> ConfigureResolution960x540()
{
return await ConfigureResolution(
hStart: 0, vStart: 0,
dvpHo: 960, dvpVo: 540,
hts: 1700, vts: 1500,
hOffset: 16, vOffset: 4
);
}
/// <summary>
/// 配置为320x240分辨率
/// </summary>
@@ -543,6 +559,9 @@ class Camera
case "640x480":
result = await ConfigureResolution640x480();
break;
case "960x540":
result = await ConfigureResolution960x540();
break;
case "1280x720":
result = await ConfigureResolution1280x720();
break;

View File

@@ -0,0 +1,118 @@
using System.Net;
using DotNext;
using Peripherals.PowerClient;
using WebProtocol;
namespace Peripherals.HdmiInClient;
static class HdmiInAddr
{
public const UInt32 BASE = 0xA000_0000;
public const UInt32 HdmiIn_CTRL = BASE + 0x0; //[0]: rstn, 0 is reset.
public const UInt32 HdmiIn_READFIFO = BASE + 0x1;
}
class HdmiIn
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 500;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
// 动态分辨率参数
private UInt16 _currentWidth = 960;
private UInt16 _currentHeight = 540;
private UInt32 _currentFrameLength = 960 * 540 * 2 / 4; // RGB565格式2字节/像素按4字节对齐
/// <summary>
/// 初始化HDMI输入客户端
/// </summary>
/// <param name="address">HDMI输入设备IP地址</param>
/// <param name="port">HDMI输入设备端口</param>
/// <param name="timeout">超时时间(毫秒)</param>
public HdmiIn(string address, int port, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.timeout = timeout;
}
public async ValueTask<Result<bool>> EnableTrans(bool isEnable)
{
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, HdmiInAddr.HdmiIn_CTRL, (isEnable ? 0x00000001u : 0x00000000u));
if (!ret.IsSuccessful)
{
logger.Error($"Failed to write HdmiIn_CTRL to HdmiIn at {this.address}:{this.port}, error: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"HdmiIn_CTRL write returned false for HdmiIn at {this.address}:{this.port}");
return false;
}
return true;
}
/// <summary>
/// 读取一帧图像数据
/// </summary>
/// <returns>包含图像数据的字节数组</returns>
public async ValueTask<Result<byte[]>> ReadFrame()
{
// 只在第一次或出错时清除UDP缓冲区避免每帧都清除造成延迟
// MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
logger.Trace($"Reading frame from HdmiIn {this.address}");
// 使用UDPClientPool读取图像帧数据
var result = await UDPClientPool.ReadAddr4BytesAsync(
this.ep,
this.taskID, // taskID
HdmiInAddr.HdmiIn_READFIFO,
(int)_currentFrameLength, // 使用当前分辨率的动态大小
BurstType.FixedBurst,
this.timeout);
if (!result.IsSuccessful)
{
logger.Error($"Failed to read frame from HdmiIn {this.address}:{this.port}, error: {result.Error}");
// 读取失败时清除缓冲区,为下次读取做准备
try
{
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
}
catch (Exception ex)
{
logger.Warn($"Failed to clear UDP data after read error: {ex.Message}");
}
return new(result.Error);
}
logger.Trace($"Successfully read frame from HdmiIn {this.address}:{this.port}, data length: {result.Value.Length} bytes");
return result.Value;
}
/// <summary>
/// 获取当前分辨率
/// </summary>
/// <returns>当前分辨率(宽度, 高度)</returns>
public (int Width, int Height) GetCurrentResolution()
{
return (_currentWidth, _currentHeight);
}
/// <summary>
/// 获取当前帧长度
/// </summary>
/// <returns>当前帧长度</returns>
public UInt32 GetCurrentFrameLength()
{
return _currentFrameLength;
}
}

View File

@@ -439,7 +439,7 @@ public class Jtag
if (retPackLen != 4)
return new(new Exception($"RecvDataPackage BodyData Length not Equal to 4: Total {retPackLen} bytes"));
return Convert.ToUInt32(Common.Number.BytesToUInt64(retPackOpts.Data).Value);
return Convert.ToUInt32(Common.Number.BytesToUInt32(retPackOpts.Data).Value);
}
async ValueTask<Result<bool>> WriteFIFO
@@ -612,13 +612,10 @@ public class Jtag
if (ret.Value)
{
var array = new UInt32[UInt32Num];
for (int i = 0; i < UInt32Num; i++)
{
var retData = await ReadFIFO(JtagAddr.READ_DATA);
if (!retData.IsSuccessful)
return new(new Exception("Read FIFO failed when Load DR"));
array[i] = retData.Value;
}
var retData = await UDPClientPool.ReadAddr4Bytes(ep, 0, JtagAddr.READ_DATA, (int)UInt32Num);
if (!retData.IsSuccessful)
return new(new Exception("Read FIFO failed when Load DR"));
Buffer.BlockCopy(retData.Value, 0, array, 0, (int)UInt32Num * 4);
return array;
}
else
@@ -788,7 +785,7 @@ public class Jtag
{
var paser = new BsdlParser.Parser();
var portNum = paser.GetBoundaryRegsNum().Value;
logger.Debug($"Get boundar scan registers number: {portNum}");
logger.Debug($"Get boundary scan registers number: {portNum}");
// Clear Data
MsgBus.UDPServer.ClearUDPData(this.address, 0);

View File

@@ -2,6 +2,7 @@ using System.Collections;
using System.Net;
using Common;
using DotNext;
using WebProtocol;
namespace Peripherals.LogicAnalyzerClient;
@@ -475,6 +476,7 @@ public class Analyzer
this.taskID,
AnalyzerAddr.STORE_OFFSET_ADDR,
capture_length,
BurstType.ExtendBurst, // 使用扩展突发读取
this.timeout
);
if (!ret.IsSuccessful)

View File

@@ -1,6 +1,7 @@
using System.Net;
using Common;
using DotNext;
using WebProtocol;
namespace Peripherals.OscilloscopeClient;
@@ -319,6 +320,7 @@ class Oscilloscope
this.taskID,
OscilloscopeAddr.RD_DATA_ADDR,
(int)OscilloscopeAddr.RD_DATA_LENGTH / 32,
BurstType.ExtendBurst, // 使用扩展突发读取
this.timeout
);
if (!ret.IsSuccessful)

View File

@@ -1109,6 +1109,7 @@ public class HttpVideoStreamService : BackgroundService
return new List<(int, int, string)>
{
(640, 480, "640x480 (VGA)"),
(960, 540, "960x540 (qHD)"),
(1280, 720, "1280x720 (HD)"),
(1280, 960, "1280x960 (SXGA)"),
(1920, 1080, "1920x1080 (Full HD)")

View File

@@ -336,7 +336,7 @@ public class UDPClientPool
$"Device {address} receive data is {retData.Length} bytes instead of 4 bytes"));
// Check result
var retCode = Convert.ToUInt32(Common.Number.BytesToUInt64(retData).Value);
var retCode = Convert.ToUInt32(Common.Number.BytesToUInt32(retData).Value);
if (Common.Number.BitsCheck(retCode, result, resultMask)) return true;
}
catch (Exception error)
@@ -433,11 +433,12 @@ public class UDPClientPool
/// <param name="endPoint">IP端点IP地址与端口</param>
/// <param name="taskID">任务ID</param>
/// <param name="devAddr">设备地址</param>
/// <param name="burstType">突发类型</param>
/// <param name="dataLength">要读取的数据长度4字节</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>读取结果,包含接收到的字节数组</returns>
public static async ValueTask<Result<byte[]>> ReadAddr4BytesAsync(
IPEndPoint endPoint, int taskID, UInt32 devAddr, int dataLength, int timeout = 1000)
IPEndPoint endPoint, int taskID, UInt32 devAddr, int dataLength, BurstType burstType, int timeout = 1000)
{
var pkgList = new List<SendAddrPackage>();
var resultData = new List<byte>();
@@ -460,7 +461,7 @@ public class UDPClientPool
var opts = new SendAddrPackOptions
{
BurstType = BurstType.FixedBurst,
BurstType = burstType,
CommandID = Convert.ToByte(taskID),
IsWrite = false,
BurstLength = (byte)(currentSegmentSize - 1),

View File

@@ -2855,17 +2855,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
@@ -2878,11 +2883,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") {
@@ -2892,12 +2897,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;
@@ -2913,6 +2925,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);
@@ -2921,7 +2940,7 @@ 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);
}
}
@@ -3137,98 +3156,14 @@ export class JtagClient {
return Promise.resolve<void>(null as any);
}
/**
* 上传比特流文件到服务器
* @param address (optional) 目标设备地址
* @param file (optional) 比特流文件
* @return 上传结果
*/
uploadBitstream(address: string | undefined, file: FileParameter | undefined, cancelToken?: CancelToken): Promise<boolean> {
let url_ = this.baseUrl + "/api/Jtag/UploadBitstream?";
if (address === null)
throw new Error("The parameter 'address' cannot be null.");
else if (address !== undefined)
url_ += "address=" + encodeURIComponent("" + address) + "&";
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.processUploadBitstream(_response);
});
}
protected processUploadBitstream(response: AxiosResponse): Promise<boolean> {
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;
result200 = resultData200 !== undefined ? resultData200 : <any>null;
return Promise.resolve<boolean>(result200);
} else if (status === 400) {
const _responseText = response.data;
let result400: any = null;
let resultData400 = _responseText;
result400 = resultData400 !== undefined ? resultData400 : <any>null;
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<boolean>(null as any);
}
/**
* 通过 JTAG 下载比特流文件到 FPGA 设备
* @param address (optional) JTAG 设备地址
* @param port (optional) JTAG 设备端口
* @param bitstreamId (optional) 比特流ID
* @return 下载结果
*/
downloadBitstream(address: string | undefined, port: number | undefined, cancelToken?: CancelToken): Promise<boolean> {
downloadBitstream(address: string | undefined, port: number | undefined, bitstreamId: number | undefined, cancelToken?: CancelToken): Promise<boolean> {
let url_ = this.baseUrl + "/api/Jtag/DownloadBitstream?";
if (address === null)
throw new Error("The parameter 'address' cannot be null.");
@@ -3238,6 +3173,10 @@ export class JtagClient {
throw new Error("The parameter 'port' cannot be null.");
else if (port !== undefined)
url_ += "port=" + encodeURIComponent("" + port) + "&";
if (bitstreamId === null)
throw new Error("The parameter 'bitstreamId' cannot be null.");
else if (bitstreamId !== undefined)
url_ += "bitstreamId=" + encodeURIComponent("" + bitstreamId) + "&";
url_ = url_.replace(/[?&]$/, "");
let options_: AxiosRequestConfig = {
@@ -6275,6 +6214,353 @@ export class RemoteUpdateClient {
}
}
export class ResourceClient {
protected instance: AxiosInstance;
protected baseUrl: string;
protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
constructor(baseUrl?: string, instance?: AxiosInstance) {
this.instance = instance || axios.create();
this.baseUrl = baseUrl ?? "http://127.0.0.1:5000";
}
/**
* 添加资源(文件上传)
* @param resourceType (optional) 资源类型
* @param resourcePurpose (optional) 资源用途template/user
* @param examID (optional) 所属实验ID可选
* @param file (optional) 资源文件
* @return 添加结果
*/
addResource(resourceType: string | undefined, resourcePurpose: string | undefined, examID: string | null | undefined, file: FileParameter | undefined, cancelToken?: CancelToken): Promise<ResourceInfo> {
let url_ = this.baseUrl + "/api/Resource";
url_ = url_.replace(/[?&]$/, "");
const content_ = new FormData();
if (resourceType === null || resourceType === undefined)
throw new Error("The parameter 'resourceType' cannot be null.");
else
content_.append("ResourceType", resourceType.toString());
if (resourcePurpose === null || resourcePurpose === undefined)
throw new Error("The parameter 'resourcePurpose' cannot be null.");
else
content_.append("ResourcePurpose", resourcePurpose.toString());
if (examID !== null && examID !== undefined)
content_.append("ExamID", examID.toString());
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.processAddResource(_response);
});
}
protected processAddResource(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 === 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<ResourceInfo>(null as any);
}
/**
* 获取资源列表
* @param examId (optional) 实验ID可选
* @param resourceType (optional) 资源类型(可选)
* @param resourcePurpose (optional) 资源用途(可选)
* @return 资源列表
*/
getResourceList(examId: string | null | undefined, resourceType: string | null | undefined, resourcePurpose: string | null | undefined, cancelToken?: CancelToken): Promise<ResourceInfo[]> {
let url_ = this.baseUrl + "/api/Resource?";
if (examId !== undefined && examId !== null)
url_ += "examId=" + encodeURIComponent("" + examId) + "&";
if (resourceType !== undefined && resourceType !== null)
url_ += "resourceType=" + encodeURIComponent("" + resourceType) + "&";
if (resourcePurpose !== undefined && resourcePurpose !== null)
url_ += "resourcePurpose=" + encodeURIComponent("" + resourcePurpose) + "&";
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.processGetResourceList(_response);
});
}
protected processGetResourceList(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 === 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 资源文件
*/
getResourceById(resourceId: number, cancelToken?: CancelToken): Promise<FileResponse> {
let url_ = this.baseUrl + "/api/Resource/{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.processGetResourceById(_response);
});
}
protected processGetResourceById(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);
}
/**
* 删除资源
* @param resourceId 资源ID
* @return 删除结果
*/
deleteResource(resourceId: number, cancelToken?: CancelToken): Promise<void> {
let url_ = this.baseUrl + "/api/Resource/{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 = {
method: "DELETE",
url: url_,
headers: {
},
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.processDeleteResource(_response);
});
}
protected processDeleteResource(response: AxiosResponse): Promise<void> {
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 === 204) {
const _responseText = response.data;
return Promise.resolve<void>(null as any);
} 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 === 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<void>(null as any);
}
}
export class TutorialClient {
protected instance: AxiosInstance;
protected baseUrl: string;
@@ -7476,12 +7762,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) {
@@ -7490,14 +7782,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"];
}
}
@@ -7511,9 +7813,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;
}
}
@@ -7522,24 +7831,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) {
@@ -7548,14 +7871,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"];
}
}
@@ -7569,9 +7903,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;
}
}
@@ -7580,58 +7922,101 @@ 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.message = _data["message"];
this.updateCount = _data["updateCount"];
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): ScanResult {
static fromJS(data: any): CreateExamRequest {
data = typeof data === 'object' ? data : {};
let result = new ScanResult();
let result = new CreateExamRequest();
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;
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 IScanResult {
/** 结果消息 */
message: string;
/** 更新的实验数量 */
updateCount: number;
/** 创建实验请求类 */
export interface ICreateExamRequest {
/** 实验ID */
id: string;
/** 实验名称 */
name: string;
/** 实验描述 */
description: string;
/** 实验标签 */
tags: string[];
/** 实验难度1-5 */
difficulty: number;
/** 普通用户是否可见 */
isVisibleToUsers: boolean;
}
/** 逻辑分析仪运行状态枚举 */
@@ -8083,6 +8468,82 @@ export interface IOscilloscopeDataResponse {
waveformData: string;
}
/** 资源信息类 */
export class ResourceInfo implements IResourceInfo {
/** 资源ID */
id!: number;
/** 资源名称 */
name!: string;
/** 资源类型 */
type!: string;
/** 资源用途template/user */
purpose!: string;
/** 上传时间 */
uploadTime!: Date;
/** 所属实验ID可选 */
examID?: string | undefined;
/** MIME类型 */
mimeType?: string | undefined;
constructor(data?: IResourceInfo) {
if (data) {
for (var property in data) {
if (data.hasOwnProperty(property))
(<any>this)[property] = (<any>data)[property];
}
}
}
init(_data?: any) {
if (_data) {
this.id = _data["id"];
this.name = _data["name"];
this.type = _data["type"];
this.purpose = _data["purpose"];
this.uploadTime = _data["uploadTime"] ? new Date(_data["uploadTime"].toString()) : <any>undefined;
this.examID = _data["examID"];
this.mimeType = _data["mimeType"];
}
}
static fromJS(data: any): ResourceInfo {
data = typeof data === 'object' ? data : {};
let result = new ResourceInfo();
result.init(data);
return result;
}
toJSON(data?: any) {
data = typeof data === 'object' ? data : {};
data["id"] = this.id;
data["name"] = this.name;
data["type"] = this.type;
data["purpose"] = this.purpose;
data["uploadTime"] = this.uploadTime ? this.uploadTime.toISOString() : <any>undefined;
data["examID"] = this.examID;
data["mimeType"] = this.mimeType;
return data;
}
}
/** 资源信息类 */
export interface IResourceInfo {
/** 资源ID */
id: number;
/** 资源名称 */
name: string;
/** 资源类型 */
type: string;
/** 资源用途template/user */
purpose: string;
/** 上传时间 */
uploadTime: Date;
/** 所属实验ID可选 */
examID?: string | undefined;
/** MIME类型 */
mimeType?: string | undefined;
}
/** Package options which to send address to read or write */
export class SendAddrPackOptions implements ISendAddrPackOptions {
/** 突发类型 */

View File

@@ -12,6 +12,14 @@ const isDarkMode = ref(
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
// Navbar显示状态管理
const showNavbar = ref(true);
// 切换Navbar显示状态
const toggleNavbar = () => {
showNavbar.value = !showNavbar.value;
};
// 初始化主题设置
onMounted(() => {
// 应用初始主题
@@ -47,6 +55,12 @@ provide("theme", {
toggleTheme,
});
// 提供Navbar控制给子组件
provide("navbar", {
showNavbar,
toggleNavbar,
});
const currentRoutePath = computed(() => {
return router.currentRoute.value.path;
});
@@ -56,8 +70,8 @@ useAlertProvider();
<template>
<div>
<header class="relative">
<Navbar />
<header class="relative" :class="{ 'navbar-hidden': !showNavbar }">
<Navbar v-show="showNavbar" />
<Dialog />
<Alert />
</header>
@@ -79,4 +93,25 @@ useAlertProvider();
<style scoped>
/* 特定于App.vue的样式 */
header {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: top;
}
.navbar-hidden {
transform: scaleY(0);
height: 0;
overflow: hidden;
}
/* Navbar显示/隐藏动画 */
header .navbar {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: top;
}
/* 当header被隐藏时确保navbar也相应变化 */
.navbar-hidden .navbar {
opacity: 0;
}
</style>

View File

@@ -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"

View File

@@ -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";
@@ -188,7 +187,6 @@ import { useAlertStore } from "@/components/Alert";
// 导入 diagram 管理器
import {
loadDiagramData,
saveDiagramData,
updatePartPosition,
updatePartAttribute,
parseConnectionPin,
@@ -217,6 +215,7 @@ const emit = defineEmits(["toggle-doc-panel", "open-components"]);
// 定义组件接受的属性
const props = defineProps<{
showDocPanel?: boolean; // 添加属性接收文档面板的显示状态
examId?: string; // 新增examId属性
}>();
// 获取componentManager实例
@@ -606,14 +605,13 @@ function onComponentDrag(e: MouseEvent) {
// 停止拖拽组件
function stopComponentDrag() {
// 如果有组件被拖拽,保存当前状态
// 如果有组件被拖拽,仅清除拖拽状态(不保存)
if (draggingComponentId.value) {
draggingComponentId.value = null;
}
isComponentDragEventActive.value = false;
saveDiagramData(diagramData.value);
// 移除自动保存功能 - 不再自动保存到localStorage
}
// 更新组件属性
@@ -977,7 +975,8 @@ function exportDiagram() {
onMounted(async () => {
// 加载图表数据
try {
diagramData.value = await loadDiagramData();
// 传入examId参数让diagramManager处理动态加载
diagramData.value = await loadDiagramData(props.examId);
// 预加载所有组件模块
const componentTypes = new Set<string>();

View File

@@ -1,7 +1,6 @@
import { ref, shallowRef, computed, reactive } from "vue";
import { createInjectionState } from "@vueuse/core";
import {
saveDiagramData,
type DiagramData,
type DiagramPart,
} from "./diagramManager";
@@ -302,7 +301,7 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
// 使用 updateDiagramDataDirectly 避免触发加载状态
canvasInstance.updateDiagramDataDirectly(currentData);
saveDiagramData(currentData);
// 移除自动保存功能
console.log("组件添加完成:", newComponent);
@@ -431,7 +430,7 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
"=== 更新图表数据完成,新组件数量:",
currentData.parts.length,
);
saveDiagramData(currentData);
// 移除自动保存功能
return { success: true, message: `已添加 ${templateData.name} 模板` };
} else {
@@ -504,7 +503,7 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
canvasInstance.updateDiagramDataDirectly(currentData);
saveDiagramData(currentData);
// 移除自动保存功能
}
/**
@@ -763,11 +762,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;
}

View File

@@ -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,62 @@ 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 resourceClient = AuthManager.createAuthenticatedResourceClient();
// 获取diagram类型的资源列表
const resources = await resourceClient.getResourceList(examId, 'canvas', 'template');
if (resources && resources.length > 0) {
// 获取第一个diagram资源
const diagramResource = resources[0];
// 使用动态API获取资源文件内容
const response = await resourceClient.getResourceById(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 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);
// 返回空的默认数据结构
@@ -114,13 +156,10 @@ export function createEmptyDiagram(): DiagramData {
};
}
// 保存图表数据本地存储
// 保存图表数据(已禁用本地存储
export function saveDiagramData(data: DiagramData): void {
try {
localStorage.setItem('diagramData', JSON.stringify(data));
} catch (error) {
console.error('Error saving diagram data:', error);
}
// 本地存储功能已禁用 - 不再保存到localStorage
console.debug('saveDiagramData called but localStorage saving is disabled');
}
// 更新组件位置

View File

@@ -588,9 +588,9 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
};
const forceCapture = async () => {
// 检查是否有其他操作正在进行
if (operationMutex.isLocked()) {
alert.warn("有其他操作正在进行中,请稍后再试", 3000);
// 检查是否正在捕获
if (!isCapturing.value) {
alert.warn("当前没有正在进行的捕获操作", 2000);
return;
}

View File

@@ -6,6 +6,7 @@ import hljs from 'highlight.js';
import 'highlight.js/styles/github.css'; // 亮色主题
// 导入主题存储
import { useThemeStore } from '@/stores/theme';
import { AuthManager } from '@/utils/AuthManager';
const props = defineProps({
content: {
@@ -15,6 +16,10 @@ const props = defineProps({
removeFirstH1: {
type: Boolean,
default: false
},
examId: {
type: String,
default: ''
}
});
@@ -23,6 +28,42 @@ const themeStore = useThemeStore();
// 使用 isDarkTheme 函数来检查当前是否为暗色主题
const isDarkMode = computed(() => themeStore.isDarkTheme());
// 图片资源缓存
const imageResourceCache = ref<Map<string, string>>(new Map());
// 获取图片资源ID的函数
async function getImageResourceId(examId: string, imagePath: string): Promise<string | null> {
try {
const client = AuthManager.createAuthenticatedResourceClient();
const resources = await client.getResourceList(examId, 'images', 'template');
// 查找匹配的图片资源
const imageResource = resources.find(r => r.name === imagePath || r.name.endsWith(imagePath));
return imageResource ? imageResource.id.toString() : null;
} catch (error) {
console.error('获取图片资源ID失败:', error);
return null;
}
}
// 通过资源ID获取图片数据URL
async function getImageDataUrl(resourceId: string): Promise<string | null> {
try {
const client = AuthManager.createAuthenticatedResourceClient();
const response = await client.getResourceById(parseInt(resourceId));
if (response && response.data) {
return URL.createObjectURL(response.data);
}
return null;
} catch (error) {
console.error('获取图片数据失败:', error);
return null;
}
}
// 监听主题变化
watch(() => themeStore.currentTheme, () => {
// 主题变化时更新代码高亮样式
@@ -54,6 +95,27 @@ const renderedContent = computed(() => {
// 创建自定义渲染器
const renderer = new marked.Renderer();
// 重写图片渲染方法,处理相对路径
renderer.image = (href, title, text) => {
let src = href;
console.log(`原始图片路径: ${href}, examId: ${props.examId}`);
// 如果是相对路径且有实验ID需要通过动态API获取
if (props.examId && href && href.startsWith('./')) {
// 对于相对路径的图片我们需要先获取图片资源ID然后通过动态API获取
// 暂时保留原始路径,在后处理中进行替换
src = href;
console.log(`保留原始路径用于后处理: ${src}`);
}
const titleAttr = title ? ` title="${title}"` : '';
const altAttr = text ? ` alt="${text}"` : '';
const dataOriginal = href && href.startsWith('./') ? ` data-original-src="${href}"` : '';
console.log(`最终渲染的HTML: <img src="${src}"${titleAttr}${altAttr}${dataOriginal} />`);
return `<img src="${src}"${titleAttr}${altAttr}${dataOriginal} />`;
};
// 重写代码块渲染方法,添加语言信息
renderer.code = (code, incomingLanguage) => {
// 确保语言参数是字符串
@@ -67,14 +129,60 @@ const renderedContent = computed(() => {
return `<pre class="hljs" data-language="${validLanguage}"><code class="language-${validLanguage}">${highlightedCode}</code></pre>`;
};
// 设置 marked 选项
marked.use({
// 设置 marked 选项并解析内容
let html = marked.parse(processedContent, {
renderer: renderer,
gfm: true,
breaks: true
});
}) as string;
return marked(processedContent);
// 后处理HTML异步处理图片
if (props.examId) {
// 查找所有需要处理的图片
const imgMatches = Array.from(html.matchAll(/(<img[^>]+data-original-src=["'])\.\/([^"']+)(["'][^>]*>)/g));
// 异步处理每个图片
imgMatches.forEach(async (match) => {
const [fullMatch, prefix, path, suffix] = match;
const imagePath = path.replace('images/', '');
// 检查缓存
if (imageResourceCache.value.has(imagePath)) {
const cachedUrl = imageResourceCache.value.get(imagePath)!;
html = html.replace(fullMatch, `${prefix}${cachedUrl}${suffix.replace(' data-original-src="./'+path+'"', '')}`);
return;
}
try {
// 获取图片资源ID
const resourceId = await getImageResourceId(props.examId, imagePath);
if (resourceId) {
// 获取图片数据URL
const dataUrl = await getImageDataUrl(resourceId);
if (dataUrl) {
// 缓存URL
imageResourceCache.value.set(imagePath, dataUrl);
// 更新HTML中的图片src
const updatedHtml = html.replace(fullMatch, `${prefix}${dataUrl}${suffix.replace(' data-original-src="./'+path+'"', '')}`);
// 触发重新渲染
setTimeout(() => {
const imgElements = document.querySelectorAll(`img[data-original-src="./${path}"]`);
imgElements.forEach(img => {
(img as HTMLImageElement).src = dataUrl;
img.removeAttribute('data-original-src');
});
}, 0);
}
}
} catch (error) {
console.error(`处理图片 ${imagePath} 失败:`, error);
}
});
}
return html;
});
// 页面挂载后,确保应用正确的主题样式

View File

@@ -31,8 +31,20 @@
<!-- 标题覆盖层 -->
<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 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>
@@ -54,6 +66,8 @@
<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 {
@@ -61,7 +75,7 @@ interface Tutorial {
title: string;
description: string;
thumbnail?: string;
docPath: string;
tags: string[];
}
// Props
@@ -81,87 +95,93 @@ let autoRotationTimer: number | null = null;
// 处理卡片点击
const handleCardClick = (index: number, tutorialId: string) => {
if (index === currentIndex.value) {
goToTutorial(tutorialId);
goToExam(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);
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;
}
// 如果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`; // 默认使用第一张图片作为缩略图
// 转换数据格式并获取封面图片
const tutorialPromises = visibleExams.map(async (exam) => {
let thumbnail: string | undefined;
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 += '...';
}
// 获取实验的封面资源(模板资源)
const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resourceList = await resourceClient.getResourceList(exam.id, 'cover', 'template');
if (resourceList && resourceList.length > 0) {
// 使用第一个封面资源
const coverResource = resourceList[0];
const fileResponse = await resourceClient.getResourceById(coverResource.id);
// 创建Blob URL作为缩略图
thumbnail = URL.createObjectURL(fileResponse.data);
}
} catch (error) {
console.warn(`无法读取例程${id}文档内容:`, error);
console.warn(`无法获取实验${exam.id}封面图片:`, error);
}
return {
id,
title,
description,
id: exam.id,
title: exam.name,
description: '点击查看实验详情',
thumbnail,
docPath: `/doc/${id}/doc.md`
tags: exam.tags || []
};
});
tutorials.value = await Promise.all(tutorialPromises);
console.log('成功加载实验数据:', tutorials.value.length, '个实验');
// 启动自动旋转
startAutoRotation();
} catch (error) {
console.error('加载例程失败:', 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);
}
});
});
// 鼠标滚轮处理
@@ -210,12 +230,12 @@ const resumeAutoRotation = () => {
}
};
// 前往例程
const goToTutorial = (tutorialId: string) => {
// 跳转到工程页面,并通过 query 参数传递文档路径
// 前往实验
const goToExam = (examId: string) => {
// 跳转到实验列表页面并传递examId参数页面将自动打开对应的实验详情模态框
router.push({
path: '/project',
query: { tutorial: tutorialId }
path: '/exam',
query: { examId: examId }
});
};

View File

@@ -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 border-2 border-base-300 rounded-lg">
<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,17 +70,20 @@
<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";
interface Props {
uploadEvent?: (file: File) => Promise<boolean>;
downloadEvent?: () => Promise<boolean>;
uploadEvent?: (file: File, examId: string) => Promise<number | null>;
downloadEvent?: (bitstreamId: number) => 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,97 @@ 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 resourceClient = AuthManager.createAuthenticatedResourceClient();
// 使用新的ResourceClient API获取比特流模板资源列表
const resources = await resourceClient.getResourceList(props.examId, 'bitstream', 'template');
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 resourceClient = AuthManager.createAuthenticatedResourceClient();
// 使用新的ResourceClient API获取资源文件
const response = await resourceClient.getResourceById(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) return;
isProgramming.value = true;
try {
const resourceClient = AuthManager.createAuthenticatedResourceClient();
if (props.downloadEvent) {
const downloadSuccess = await props.downloadEvent(bitstream.id);
if (downloadSuccess) {
dialog.info("示例比特流烧录成功");
} else {
dialog.error("烧录失败");
}
} else {
dialog.info("示例比特流props.downloadEvent未定义 无法烧录");
}
} 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]; // 获取选中的第一个文件
@@ -85,6 +218,7 @@ function checkFile(file: File): boolean {
}
async function handleClick(event: Event): Promise<void> {
console.log("上传按钮被点击");
if (isNull(bitstream.value) || isUndefined(bitstream.value)) {
dialog.error(`未选择文件`);
return;
@@ -97,19 +231,21 @@ async function handleClick(event: Event): Promise<void> {
}
isUploading.value = true;
let uploadedBitstreamId: number | null = null;
try {
const ret = await props.uploadEvent(bitstream.value);
console.log("开始上传比特流文件:", bitstream.value.name);
const bitstreamId = await props.uploadEvent(bitstream.value, props.examId || '');
console.log("上传结果ID:", bitstreamId);
if (isUndefined(props.downloadEvent)) {
if (ret) {
dialog.info("上传成功");
emits("finishedUpload", bitstream.value);
} else dialog.error("上传失败");
return;
}
if (!ret) {
console.log("上传成功,下载未定义");
isUploading.value = false;
return;
}
if (bitstreamId === null || bitstreamId === undefined) {
isUploading.value = false;
return;
}
uploadedBitstreamId = bitstreamId;
} catch (e) {
dialog.error("上传失败");
console.error(e);
@@ -118,9 +254,14 @@ async function handleClick(event: Event): Promise<void> {
// Download
try {
const ret = await props.downloadEvent();
if (ret) dialog.info("下载成功");
else dialog.error("下载失败");
console.log("开始下载比特流ID:", uploadedBitstreamId);
if (uploadedBitstreamId === null || uploadedBitstreamId === undefined) {
dialog.error("uploadedBitstreamId is null or undefined");
} else {
const ret = await props.downloadEvent(uploadedBitstreamId);
if (ret) dialog.info("下载成功");
else dialog.error("下载失败");
}
} catch (e) {
dialog.error("下载失败");
console.error(e);

View File

@@ -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<{

View File

@@ -22,9 +22,9 @@
</div>
<div class="divider"></div>
<UploadCard
class="bg-base-200"
:exam-id="props.examId"
:upload-event="eqps.jtagUploadBitstream"
:download-event="eqps.jtagDownloadBitstream"
:download-event="handleDownloadBitstream"
:bitstream-file="eqps.jtagBitstream"
@update:bitstream-file="handleBitstreamChange"
>
@@ -91,6 +91,7 @@ import { RefreshCcwIcon } from "lucide-vue-next";
interface CapsProps {
jtagFreq?: string;
examId?: string; // 新增examId属性
}
const emits = defineEmits<{
@@ -127,6 +128,11 @@ function handleBitstreamChange(file: File | undefined) {
eqps.jtagBitstream = file;
}
async function handleDownloadBitstream(bitstreamId: number): Promise<boolean> {
console.log("开始下载比特流ID:", bitstreamId);
return await eqps.jtagDownloadBitstream(bitstreamId);
}
function handleSelectJtagSpeed(event: Event) {
const target = event.target as HTMLSelectElement;
eqps.jtagSetSpeed(target.selectedIndex);

View File

@@ -1,7 +1,7 @@
import { ref, reactive, watchPostEffect, onMounted, onUnmounted } from "vue";
import { defineStore } from "pinia";
import { useLocalStorage } from "@vueuse/core";
import { isString, toNumber, type Dictionary } from "lodash";
import { isString, toNumber, isUndefined, type Dictionary } from "lodash";
import z from "zod";
import { isNumber } from "mathjs";
import { Mutex, withTimeout } from "async-mutex";
@@ -9,8 +9,10 @@ import { useConstraintsStore } from "@/stores/constraints";
import { useDialogStore } from "./dialog";
import { toFileParameterOrUndefined } from "@/utils/Common";
import { AuthManager } from "@/utils/AuthManager";
import { HubConnectionBuilder } from "@microsoft/signalr";
import { HubConnection, HubConnectionBuilder } from "@microsoft/signalr";
import { getHubProxyFactory, getReceiverRegister } from "@/TypedSignalR.Client";
import type { ResourceInfo } from "@/APIClient";
import type { IJtagHub } from "@/TypedSignalR.Client/server.Hubs.JtagHub";
export const useEquipments = defineStore("equipments", () => {
// Global Stores
@@ -23,27 +25,38 @@ export const useEquipments = defineStore("equipments", () => {
// Jtag
const jtagBitstream = ref<File>();
const jtagBoundaryScanFreq = ref(100);
const jtagUserBitstreams = ref<ResourceInfo[]>([]);
const jtagClientMutex = withTimeout(
new Mutex(),
1000,
new Error("JtagClient Mutex Timeout!"),
);
const jtagHubConnection = new HubConnectionBuilder()
.withUrl("/hubs/JtagHub")
.withAutomaticReconnect()
.build();
const jtagHubProxy =
getHubProxyFactory("IJtagHub").createHubProxy(jtagHubConnection);
const jtagHubSubscription = getReceiverRegister("IJtagReceiver").register(
jtagHubConnection,
{
const jtagHubConnection = ref<HubConnection>();
const jtagHubProxy = ref<IJtagHub>();
onMounted(async () => {
// 每次挂载都重新创建连接
jtagHubConnection.value =
AuthManager.createAuthenticatedJtagHubConnection();
jtagHubProxy.value = getHubProxyFactory("IJtagHub").createHubProxy(
jtagHubConnection.value,
);
getReceiverRegister("IJtagReceiver").register(jtagHubConnection.value, {
onReceiveBoundaryScanData: async (msg) => {
constrainsts.batchSetConstraintStates(msg);
},
},
);
onMounted(() => {
jtagHubConnection.start();
});
await jtagHubConnection.value.start();
});
onUnmounted(() => {
// 断开连接,清理资源
if (jtagHubConnection.value) {
jtagHubConnection.value.stop();
jtagHubConnection.value = undefined;
jtagHubProxy.value = undefined;
}
});
// Matrix Key
@@ -87,33 +100,61 @@ export const useEquipments = defineStore("equipments", () => {
}
async function jtagBoundaryScanSetOnOff(enable: boolean) {
enableJtagBoundaryScan.value = enable;
if (enable) {
jtagHubProxy.startBoundaryScan(jtagBoundaryScanFreq.value);
} else {
jtagHubProxy.stopBoundaryScan();
if (isUndefined(jtagHubProxy.value)) {
console.error("JtagHub Not Initialize...");
return;
}
if (enable) {
const ret = await jtagHubProxy.value.startBoundaryScan(
jtagBoundaryScanFreq.value,
);
if (!ret) {
console.error("Failed to start boundary scan");
return;
}
} else {
const ret = await jtagHubProxy.value.stopBoundaryScan();
if (!ret) {
console.error("Failed to stop boundary scan");
return;
}
}
enableJtagBoundaryScan.value = enable;
}
async function jtagUploadBitstream(bitstream: File): Promise<boolean> {
async function jtagUploadBitstream(bitstream: File, examId?: string): Promise<number | null> {
try {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const resp = await jtagClient.uploadBitstream(
boardAddr.value,
const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resp = await resourceClient.addResource(
'bitstream',
'user',
examId || null,
toFileParameterOrUndefined(bitstream),
);
return resp;
// 如果上传成功,设置为当前选中的比特流
if (resp && resp.id !== undefined && resp.id !== null) {
return resp.id;
}
return null;
} catch (e) {
dialog.error("上传错误");
console.error(e);
return false;
return null;
}
}
async function jtagDownloadBitstream(): Promise<boolean> {
async function jtagDownloadBitstream(bitstreamId?: number): Promise<boolean> {
if (bitstreamId === null || bitstreamId === undefined) {
dialog.error("请先选择要下载的比特流");
return false;
}
const release = await jtagClientMutex.acquire();
try {
// 自动开启电源
@@ -123,10 +164,11 @@ export const useEquipments = defineStore("equipments", () => {
const resp = await jtagClient.downloadBitstream(
boardAddr.value,
boardPort.value,
bitstreamId,
);
return resp;
} catch (e) {
dialog.error("上传错误");
dialog.error("下载错误");
console.error(e);
return false;
} finally {
@@ -255,6 +297,7 @@ export const useEquipments = defineStore("equipments", () => {
jtagBoundaryScanSetOnOff,
jtagBitstream,
jtagBoundaryScanFreq,
jtagUserBitstreams,
jtagUploadBitstream,
jtagDownloadBitstream,
jtagGetIDCode,

View File

@@ -14,8 +14,12 @@ import {
OscilloscopeApiClient,
DebuggerClient,
ExamClient,
ResourceClient,
} from "@/APIClient";
import router from "@/router";
import { HubConnectionBuilder } from "@microsoft/signalr";
import axios, { type AxiosInstance } from "axios";
import { isNull } from "lodash";
// 支持的客户端类型联合类型
type SupportedClient =
@@ -33,7 +37,8 @@ type SupportedClient =
| NetConfigClient
| OscilloscopeApiClient
| DebuggerClient
| ExamClient;
| ExamClient
| ResourceClient;
export class AuthManager {
// 存储token到localStorage
@@ -117,7 +122,7 @@ export class AuthManager {
if (!token) return null;
const instance = axios.create();
instance.interceptors.request.use(config => {
instance.interceptors.request.use((config) => {
config.headers = config.headers || {};
(config.headers as any)["Authorization"] = `Bearer ${token}`;
return config;
@@ -196,6 +201,25 @@ export class AuthManager {
return AuthManager.createAuthenticatedClient(ExamClient);
}
public static createAuthenticatedResourceClient(): ResourceClient {
return AuthManager.createAuthenticatedClient(ResourceClient);
}
public static createAuthenticatedJtagHubConnection() {
const token = this.getToken();
if (isNull(token)) {
router.push("/login");
throw Error("Token Null!");
}
return new HubConnectionBuilder()
.withUrl("http://127.0.0.1:5000/hubs/JtagHub", {
accessTokenFactory: () => token,
})
.withAutomaticReconnect()
.build();
}
// 登录函数
public static async login(
username: string,

File diff suppressed because it is too large Load Diff

View File

@@ -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">

View File

@@ -1,6 +1,6 @@
<template>
<div class="h-full flex flex-col gap-7">
<div class="tabs tabs-box flex-shrink-0 shadow-xl mx-5">
<div class="tabs tabs-lift flex-shrink-0 mx-5">
<label class="tab">
<input
type="radio"

View File

@@ -29,6 +29,7 @@
<DiagramCanvas
ref="diagramCanvas"
:showDocPanel="showDocPanel"
:exam-id="(route.query.examId as string) || ''"
@open-components="openComponentsMenu"
@toggle-doc-panel="toggleDocPanel"
/>
@@ -36,13 +37,13 @@
<!-- 拖拽分割线 -->
<SplitterResizeHandle
id="splitter-group-h-resize-handle"
class="w-2 bg-base-100 hover:bg-primary hover:opacity-70 transition-colors"
class="w-1 bg-base-300"
/>
<!-- 右侧编辑区域 -->
<SplitterPanel
id="splitter-group-h-panel-properties"
:min-size="20"
class="bg-base-200 h-full overflow-hidden flex flex-col"
class="bg-base-100 h-full overflow-hidden flex flex-col"
>
<div class="overflow-y-auto flex-1">
<!-- 使用条件渲染显示不同的面板 -->
@@ -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>
@@ -70,7 +74,7 @@
<SplitterResizeHandle
v-show="!isBottomBarFullscreen"
id="splitter-group-v-resize-handle"
class="h-2 bg-base-100 hover:bg-primary hover:opacity-70 transition-colors"
class="h-1 bg-base-300"
/>
<!-- 功能底栏 -->
@@ -100,11 +104,32 @@
@close="handleRequestBoardClose"
@success="handleRequestBoardSuccess"
/>
<!-- Navbar切换浮动按钮 -->
<div
class="navbar-toggle-btn"
:class="{ 'with-navbar': navbarControl.showNavbar.value }"
>
<button
@click="navbarControl.toggleNavbar"
class="btn btn-circle btn-primary shadow-lg hover:shadow-xl transition-all duration-300"
:class="{ 'btn-outline': navbarControl.showNavbar.value }"
:title="navbarControl.showNavbar.value ? '隐藏顶部导航栏' : '显示顶部导航栏'"
>
<!-- 使用SVG图标表示菜单/关闭状态 -->
<svg v-if="navbarControl.showNavbar.value" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { ref, onMounted, watch, inject, type Ref } from "vue";
import { useRouter } from "vue-router";
import { useLocalStorage } from '@vueuse/core'; // 添加VueUse导入
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from "reka-ui";
@@ -115,7 +140,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";
@@ -133,6 +157,12 @@ const equipments = useEquipments();
const alert = useAlertStore();
// --- Navbar控制 ---
const navbarControl = inject('navbar') as {
showNavbar: Ref<boolean>;
toggleNavbar: () => void;
};
// --- 使用VueUse保存分栏状态 ---
// 左右分栏比例默认60%
const horizontalSplitterSize = useLocalStorage('project-horizontal-splitter-size', 60);
@@ -182,29 +212,37 @@ async function toggleDocPanel() {
// 加载文档内容
async function loadDocumentContent() {
try {
// 从路由参数中获取教程ID
const tutorialId = (route.query.tutorial as string) || "02"; // 默认加载02例程
// 检查是否有实验ID参数
const examId = route.query.examId as string;
if (examId) {
// 如果有实验ID从API加载实验文档
console.log('加载实验文档:', examId);
const client = AuthManager.createAuthenticatedResourceClient();
// 构建文档路径
let docPath = `/doc/${tutorialId}/doc.md`;
// 获取markdown类型的模板资源列表
const resources = await client.getResourceList(examId, 'doc', 'template');
// 检查当前路径是否包含下划线(例如 02_key 格式)
// 如果不包含,那么使用更新的命名格式
if (!tutorialId.includes("_")) {
docPath = `/doc/${tutorialId}/doc.md`;
if (resources && resources.length > 0) {
// 获取第一个markdown资源
const markdownResource = resources[0];
// 使用新的ResourceClient API获取资源文件内容
const response = await client.getResourceById(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无法加载请求的文档。";
@@ -268,8 +306,8 @@ async function checkAndInitializeBoard() {
// 根据实验板信息更新equipment store
function updateEquipmentFromBoard(board: Board) {
equipments.setAddr(board.ipAddr);
equipments.setPort(board.port);
equipments.boardAddr = board.ipAddr;
equipments.boardPort = board.port;
console.log(`实验板信息已更新到equipment store:`, {
address: board.ipAddr,
@@ -312,8 +350,8 @@ onMounted(async () => {
// 检查并初始化用户实验板
await checkAndInitializeBoard();
// 检查是否有例程参数,如果有则自动打开文档面板
if (route.query.tutorial) {
// 检查是否有例程参数或实验ID参数,如果有则自动打开文档面板
if (route.query.tutorial || route.query.examId) {
showDocPanel.value = true;
await loadDocumentContent();
}
@@ -344,7 +382,7 @@ onMounted(async () => {
}
}
/* 确保滚动行为仅在需要时出现 */
/* 确保整个页面禁止滚动 */
html,
body {
overflow: hidden;
@@ -376,7 +414,42 @@ body {
:deep(.markdown-content) {
padding: 1rem;
background-color: hsl(var(--b1));
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* Navbar切换浮动按钮样式 */
.navbar-toggle-btn {
position: fixed;
top: 20px;
left: 20px;
z-index: 1000;
transition: all 0.3s ease-in-out;
}
/* 当Navbar显示时调整按钮位置 */
.navbar-toggle-btn.with-navbar {
top: 80px; /* 调整到Navbar下方 */
}
.navbar-toggle-btn button {
backdrop-filter: blur(10px);
background: rgba(var(--p), 0.9);
border: 2px solid rgba(var(--p), 0.3);
transition: all 0.3s ease-in-out;
}
.navbar-toggle-btn button:hover {
transform: scale(1.1);
background: rgba(var(--p), 1);
}
.navbar-toggle-btn button.btn-outline {
background: rgba(var(--b1), 0.9);
color: hsl(var(--p));
border: 2px solid rgba(var(--p), 0.5);
}
.navbar-toggle-btn button.btn-outline:hover {
background: rgba(var(--p), 0.1);
border: 2px solid rgba(var(--p), 0.8);
}
</style>

View File

@@ -53,10 +53,6 @@ export default defineConfig({
target: "http://localhost:5000",
changeOrigin: true,
},
"/hubs": {
target: "http://localhost:5000",
changeOrigin: true,
},
},
port: 5173,
},