3 Commits

10 changed files with 822 additions and 108 deletions

View File

@@ -145,6 +145,8 @@ try
// 添加 HTTP 视频流服务
builder.Services.AddSingleton<HttpVideoStreamService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpVideoStreamService>());
builder.Services.AddSingleton<HttpHdmiVideoStreamService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpHdmiVideoStreamService>());
// Application Settings
var app = builder.Build();

View File

@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using server.Services;
using Database;
namespace server.Controllers;
[ApiController]
[Route("api/[controller]")]
[EnableCors("Users")]
public class HdmiVideoStreamController : ControllerBase
{
private readonly HttpHdmiVideoStreamService _videoStreamService;
private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
public HdmiVideoStreamController(HttpHdmiVideoStreamService videoStreamService)
{
_videoStreamService = videoStreamService;
}
// 管理员获取所有板子的 endpoints
[HttpGet("AllEndpoints")]
[Authorize("Admin")]
public ActionResult<List<HdmiVideoStreamEndpoint>> GetAllEndpoints()
{
var endpoints = _videoStreamService.GetAllVideoEndpoints();
if (endpoints == null)
return NotFound("No boards found.");
return Ok(endpoints);
}
// 用户获取自己板子的 endpoint
[HttpGet("MyEndpoint")]
[Authorize]
public ActionResult<HdmiVideoStreamEndpoint> GetMyEndpoint()
{
var userName = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrEmpty(userName))
return Unauthorized("User name not found in claims.");
var db = new AppDataConnection();
if (db == null)
return NotFound("Database connection failed.");
var userRet = db.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return NotFound("User not found.");
var user = userRet.Value.Value;
var boardId = user.BoardID;
if (boardId == Guid.Empty)
return NotFound("No board bound to this user.");
var boardRet = db.GetBoardByID(boardId);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return NotFound("Board not found.");
var endpoint = _videoStreamService.GetVideoEndpoint(boardId.ToString());
return Ok(endpoint);
}
}

View File

@@ -259,7 +259,8 @@ public class LogicAnalyzerController : ControllerBase
{
try
{
if (capture_length < 0 || capture_length > 2048*32)
//DDR深度为 32'h01000000 - 32'h0FFFFFFF
if (capture_length < 0 || capture_length > 0x10000000 - 0x01000000)
return BadRequest("采样深度设置错误");
if (pre_capture_length < 0 || pre_capture_length >= capture_length)
return BadRequest("预采样深度必须小于捕获深度");

View File

@@ -32,14 +32,16 @@ class HdmiIn
/// </summary>
/// <param name="address">HDMI输入设备IP地址</param>
/// <param name="port">HDMI输入设备端口</param>
/// <param name="taskID">任务ID</param>
/// <param name="timeout">超时时间(毫秒)</param>
public HdmiIn(string address, int port, int timeout = 500)
public HdmiIn(string address, int port, int taskID, 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.taskID = taskID;
this.timeout = timeout;
}

View File

@@ -70,7 +70,7 @@ static class AnalyzerAddr
public const UInt32 DMA1_START_WRITE_ADDR = DMA1_BASE + 0x0000_0012;
public const UInt32 DMA1_END_WRITE_ADDR = DMA1_BASE + 0x0000_0013;
public const UInt32 DMA1_CAPTURE_CTRL_ADDR = DMA1_BASE + 0x0000_0014;
public const UInt32 STORE_OFFSET_ADDR = DDR_BASE + 0x0010_0000;
public const UInt32 STORE_OFFSET_ADDR = DDR_BASE + 0x0100_0000;
/// <summary>
/// 0x0100_0000 - 0x0100_03FF 只读 32位波形存储得到的32位数据中低八位最先捕获高八位最后捕获。<br/>

View File

@@ -0,0 +1,234 @@
using System.Net;
using System.Collections.Concurrent;
using Peripherals.HdmiInClient;
namespace server.Services;
public class HdmiVideoStreamEndpoint
{
public string BoardId { get; set; } = "";
public string MjpegUrl { get; set; } = "";
public string VideoUrl { get; set; } = "";
public string SnapshotUrl { get; set; } = "";
}
public class HttpHdmiVideoStreamService : BackgroundService
{
private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private HttpListener? _httpListener;
private readonly int _serverPort = 4322;
private readonly ConcurrentDictionary<string, HdmiIn> _hdmiInDict = new();
private bool _isEnabled = true;
public override async Task StartAsync(CancellationToken cancellationToken)
{
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://*:{_serverPort}/");
_httpListener.Start();
logger.Info($"HDMI Video Stream Service started on port {_serverPort}");
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
while (!stoppingToken.IsCancellationRequested)
{
HttpListenerContext? context = null;
try
{
context = await _httpListener.GetContextAsync();
}
catch (ObjectDisposedException)
{
// Listener closed, exit loop
break;
}
catch (HttpListenerException)
{
// Listener closed, exit loop
break;
}
catch (Exception ex)
{
logger.Error(ex, "Error in GetContextAsync");
break;
}
if (context != null)
_ = HandleRequestAsync(context, stoppingToken);
}
}
finally
{
_httpListener?.Close();
logger.Info("HDMI Video Stream Service stopped.");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
logger.Info("Stopping HDMI Video Stream Service...");
_isEnabled = false;
_httpListener?.Close(); // 立即关闭监听器,唤醒阻塞
await base.StopAsync(cancellationToken);
}
// 获取/创建 HdmiIn 实例
private HdmiIn? GetOrCreateHdmiIn(string boardId)
{
if (_hdmiInDict.TryGetValue(boardId, out var hdmiIn))
return hdmiIn;
var db = new Database.AppDataConnection();
if (db == null)
{
logger.Error("Failed to create HdmiIn instance");
return null;
}
var boardRet = db.GetBoardByID(Guid.Parse(boardId));
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
{
logger.Error($"Failed to get board with ID {boardId}");
return null;
}
var board = boardRet.Value.Value;
hdmiIn = new HdmiIn(board.IpAddr, board.Port, 0); // taskID 可根据实际需求调整
_hdmiInDict[boardId] = hdmiIn;
return hdmiIn;
}
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
{
var path = context.Request.Url?.AbsolutePath ?? "/";
var boardId = context.Request.QueryString["boardId"];
if (string.IsNullOrEmpty(boardId))
{
await SendErrorAsync(context.Response, "Missing boardId");
return;
}
var hdmiIn = GetOrCreateHdmiIn(boardId);
if (hdmiIn == null)
{
await SendErrorAsync(context.Response, "Invalid boardId or board not available");
return;
}
if (path == "/snapshot")
{
await HandleSnapshotRequestAsync(context.Response, hdmiIn, cancellationToken);
}
else if (path == "/mjpeg")
{
await HandleMjpegStreamAsync(context.Response, hdmiIn, cancellationToken);
}
else if (path == "/video")
{
await SendVideoHtmlPageAsync(context.Response, boardId);
}
else
{
await SendIndexHtmlPageAsync(context.Response, boardId);
}
}
private async Task HandleSnapshotRequestAsync(HttpListenerResponse response, HdmiIn hdmiIn, CancellationToken cancellationToken)
{
var frameResult = await hdmiIn.ReadFrame();
if (frameResult.IsSuccessful)
{
response.ContentType = "image/jpeg";
await response.OutputStream.WriteAsync(frameResult.Value, 0, frameResult.Value.Length, cancellationToken);
}
else
{
response.StatusCode = 500;
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes("Failed to get snapshot"));
}
response.Close();
}
private async Task HandleMjpegStreamAsync(HttpListenerResponse response, HdmiIn hdmiIn, CancellationToken cancellationToken)
{
response.ContentType = "multipart/x-mixed-replace; boundary=--frame";
while (!cancellationToken.IsCancellationRequested && _isEnabled)
{
var frameResult = await hdmiIn.ReadFrame();
if (frameResult.IsSuccessful)
{
var header = $"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {frameResult.Value.Length}\r\n\r\n";
await response.OutputStream.WriteAsync(System.Text.Encoding.ASCII.GetBytes(header));
await response.OutputStream.WriteAsync(frameResult.Value, 0, frameResult.Value.Length, cancellationToken);
await response.OutputStream.WriteAsync(System.Text.Encoding.ASCII.GetBytes("\r\n"));
}
await Task.Delay(33, cancellationToken); // ~30fps
}
response.Close();
}
private async Task SendVideoHtmlPageAsync(HttpListenerResponse response, string boardId)
{
string html = $@"<html><body>
<h1>HDMI Video Stream for Board {boardId}</h1>
<img src='/mjpeg?boardId={boardId}' />
</body></html>";
response.ContentType = "text/html";
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(html));
response.Close();
}
private async Task SendIndexHtmlPageAsync(HttpListenerResponse response, string boardId)
{
string html = $@"<html><body>
<h1>Welcome to HDMI Video Stream Service</h1>
<a href='/video?boardId={boardId}'>View Video Stream for Board {boardId}</a>
</body></html>";
response.ContentType = "text/html";
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(html));
response.Close();
}
private async Task SendErrorAsync(HttpListenerResponse response, string message)
{
response.StatusCode = 400;
await response.OutputStream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(message));
response.Close();
}
public List<HdmiVideoStreamEndpoint>? GetAllVideoEndpoints()
{
var db = new Database.AppDataConnection();
var boards = db?.GetAllBoard();
if (boards == null)
return null;
var endpoints = new List<HdmiVideoStreamEndpoint>();
foreach (var board in boards)
{
endpoints.Add(new HdmiVideoStreamEndpoint
{
BoardId = board.ID.ToString(),
MjpegUrl = $"http://{Global.localhost}:{_serverPort}/mjpeg?boardId={board.ID}",
VideoUrl = $"http://{Global.localhost}:{_serverPort}/video?boardId={board.ID}",
SnapshotUrl = $"http://{Global.localhost}:{_serverPort}/snapshot?boardId={board.ID}"
});
}
return endpoints;
}
public HdmiVideoStreamEndpoint GetVideoEndpoint(string boardId)
{
return new HdmiVideoStreamEndpoint
{
BoardId = boardId,
MjpegUrl = $"http://{Global.localhost}:{_serverPort}/mjpeg?boardId={boardId}",
VideoUrl = $"http://{Global.localhost}:{_serverPort}/video?boardId={boardId}",
SnapshotUrl = $"http://{Global.localhost}:{_serverPort}/snapshot?boardId={boardId}"
};
}
}

View File

@@ -2944,6 +2944,123 @@ export class ExamClient {
}
}
export class HdmiVideoStreamClient {
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";
}
getAllEndpoints( cancelToken?: CancelToken): Promise<HdmiVideoStreamEndpoint[]> {
let url_ = this.baseUrl + "/api/HdmiVideoStream/AllEndpoints";
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.processGetAllEndpoints(_response);
});
}
protected processGetAllEndpoints(response: AxiosResponse): Promise<HdmiVideoStreamEndpoint[]> {
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(HdmiVideoStreamEndpoint.fromJS(item));
}
else {
result200 = <any>null;
}
return Promise.resolve<HdmiVideoStreamEndpoint[]>(result200);
} else if (status !== 200 && status !== 204) {
const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}
return Promise.resolve<HdmiVideoStreamEndpoint[]>(null as any);
}
getMyEndpoint( cancelToken?: CancelToken): Promise<HdmiVideoStreamEndpoint> {
let url_ = this.baseUrl + "/api/HdmiVideoStream/MyEndpoint";
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.processGetMyEndpoint(_response);
});
}
protected processGetMyEndpoint(response: AxiosResponse): Promise<HdmiVideoStreamEndpoint> {
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 = HdmiVideoStreamEndpoint.fromJS(resultData200);
return Promise.resolve<HdmiVideoStreamEndpoint>(result200);
} else if (status !== 200 && status !== 204) {
const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}
return Promise.resolve<HdmiVideoStreamEndpoint>(null as any);
}
}
export class JtagClient {
protected instance: AxiosInstance;
protected baseUrl: string;
@@ -8019,6 +8136,54 @@ export interface ICreateExamRequest {
isVisibleToUsers: boolean;
}
export class HdmiVideoStreamEndpoint implements IHdmiVideoStreamEndpoint {
boardId!: string;
mjpegUrl!: string;
videoUrl!: string;
snapshotUrl!: string;
constructor(data?: IHdmiVideoStreamEndpoint) {
if (data) {
for (var property in data) {
if (data.hasOwnProperty(property))
(<any>this)[property] = (<any>data)[property];
}
}
}
init(_data?: any) {
if (_data) {
this.boardId = _data["boardId"];
this.mjpegUrl = _data["mjpegUrl"];
this.videoUrl = _data["videoUrl"];
this.snapshotUrl = _data["snapshotUrl"];
}
}
static fromJS(data: any): HdmiVideoStreamEndpoint {
data = typeof data === 'object' ? data : {};
let result = new HdmiVideoStreamEndpoint();
result.init(data);
return result;
}
toJSON(data?: any) {
data = typeof data === 'object' ? data : {};
data["boardId"] = this.boardId;
data["mjpegUrl"] = this.mjpegUrl;
data["videoUrl"] = this.videoUrl;
data["snapshotUrl"] = this.snapshotUrl;
return data;
}
}
export interface IHdmiVideoStreamEndpoint {
boardId: string;
mjpegUrl: string;
videoUrl: string;
snapshotUrl: string;
}
/** 逻辑分析仪运行状态枚举 */
export enum CaptureStatus {
None = 0,

View File

@@ -76,28 +76,12 @@ const channelDivOptions = [
{ value: 32, label: "32通道", description: "启用32个通道 (CH0-CH31)" },
];
// 捕获深度选项
const captureLengthOptions = [
{ value: 256, label: "256" },
{ value: 512, label: "512" },
{ value: 1024, label: "1K" },
{ value: 2048, label: "2K" },
{ value: 4096, label: "4K" },
{ value: 8192, label: "8K" },
{ value: 16384, label: "16K" },
{ value: 32768, label: "32K" },
];
// 捕获深度限制常量
const CAPTURE_LENGTH_MIN = 1024; // 最小捕获深度 1024
const CAPTURE_LENGTH_MAX = 0x10000000 - 0x01000000; // 最大捕获深度
// 预捕获深度选项
const preCaptureLengthOptions = [
{ value: 0, label: "0" },
{ value: 16, label: "16" },
{ value: 32, label: "32" },
{ value: 64, label: "64" },
{ value: 128, label: "128" },
{ value: 256, label: "256" },
{ value: 512, label: "512" },
];
// 预捕获深度限制常量
const PRE_CAPTURE_LENGTH_MIN = 0; // 最小预捕获深度 0
// 默认颜色数组
const defaultColors = [
@@ -126,8 +110,8 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 触发设置相关状态
const currentGlobalMode = ref<GlobalCaptureMode>(GlobalCaptureMode.AND);
const currentChannelDiv = ref<number>(8); // 默认启用8个通道
const captureLength = ref<number>(1024); // 捕获深度,默认1024
const preCaptureLength = ref<number>(0); // 预捕获深度默认0
const captureLength = ref<number>(CAPTURE_LENGTH_MIN); // 捕获深度,默认为最小值
const preCaptureLength = ref<number>(PRE_CAPTURE_LENGTH_MIN); // 预捕获深度默认0
const isApplying = ref(false);
const isCapturing = ref(false); // 添加捕获状态标识
@@ -181,6 +165,64 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
}
};
// 验证捕获深度
const validateCaptureLength = (value: number): { valid: boolean; message?: string } => {
if (!Number.isInteger(value)) {
return { valid: false, message: "捕获深度必须是整数" };
}
if (value < CAPTURE_LENGTH_MIN) {
return { valid: false, message: `捕获深度不能小于 ${CAPTURE_LENGTH_MIN}` };
}
if (value > CAPTURE_LENGTH_MAX) {
return { valid: false, message: `捕获深度不能大于 ${CAPTURE_LENGTH_MAX.toLocaleString()}` };
}
return { valid: true };
};
// 验证预捕获深度
const validatePreCaptureLength = (value: number, currentCaptureLength: number): { valid: boolean; message?: string } => {
if (!Number.isInteger(value)) {
return { valid: false, message: "预捕获深度必须是整数" };
}
if (value < PRE_CAPTURE_LENGTH_MIN) {
return { valid: false, message: `预捕获深度不能小于 ${PRE_CAPTURE_LENGTH_MIN}` };
}
if (value >= currentCaptureLength) {
return { valid: false, message: `预捕获深度不能大于等于捕获深度 (${currentCaptureLength})` };
}
return { valid: true };
};
// 设置捕获深度
const setCaptureLength = (value: number) => {
const validation = validateCaptureLength(value);
if (!validation.valid) {
alert?.error(validation.message!, 3000);
return false;
}
// 检查预捕获深度是否仍然有效
if (preCaptureLength.value >= value) {
preCaptureLength.value = Math.max(0, value - 1);
alert?.warn(`预捕获深度已自动调整为 ${preCaptureLength.value}`, 3000);
}
captureLength.value = value;
return true;
};
// 设置预捕获深度
const setPreCaptureLength = (value: number) => {
const validation = validatePreCaptureLength(value, captureLength.value);
if (!validation.valid) {
alert?.error(validation.message!, 3000);
return false;
}
preCaptureLength.value = value;
return true;
};
// 设置通道组
const setChannelDiv = (channelCount: number) => {
// 验证通道数量是否有效
@@ -717,8 +759,15 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
operators,
signalValues,
channelDivOptions, // 导出通道组选项
captureLengthOptions, // 导出捕获深度选项
preCaptureLengthOptions, // 导出预捕获深度选项
// 捕获深度常量和验证
CAPTURE_LENGTH_MIN,
CAPTURE_LENGTH_MAX,
PRE_CAPTURE_LENGTH_MIN,
validateCaptureLength,
validatePreCaptureLength,
setCaptureLength,
setPreCaptureLength,
// 触发设置方法
setChannelDiv, // 导出设置通道组方法

View File

@@ -3,89 +3,182 @@
<!-- 通道配置 -->
<div class="form-control">
<!-- 全局触发模式选择和通道组配置 -->
<div class="flex flex-col lg:flex-row justify-between gap-4 my-4 mx-2">
<!-- 左侧全局触发模式和通道组选择 -->
<div class="flex flex-col lg:flex-row gap-4">
<div class="flex flex-row gap-2 items-center">
<label class="label">
<span class="label-text text-sm">全局触发逻辑</span>
<div class="flex flex-col gap-6 my-4 mx-2">
<div class="flex flex-col lg:flex-row gap-6">
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased">
全局触发逻辑
</label>
<select
v-model="currentGlobalMode"
@change="setGlobalMode(currentGlobalMode)"
class="select select-sm select-bordered"
<div class="relative w-[200px]">
<button
tabindex="0"
type="button"
class="flex items-center gap-4 justify-between h-max outline-none focus:outline-none bg-transparent ring-transparent border border-slate-200 transition-all duration-300 ease-in disabled:opacity-50 disabled:pointer-events-none select-none text-start text-sm rounded-md py-2 px-2.5 ring shadow-sm hover:border-slate-800 hover:ring-slate-800/10 focus:border-slate-800 focus:ring-slate-800/10 w-full"
@click="toggleGlobalModeDropdown"
:aria-expanded="showGlobalModeDropdown"
aria-haspopup="listbox"
role="combobox"
>
<option
<span>{{ currentGlobalModeLabel }}</span>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-[1em] w-[1em] translate-x-0.5 stroke-[1.5]">
<path d="M17 8L12 3L7 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M17 16L12 21L7 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<input readonly style="display:none" :value="currentGlobalMode" />
<!-- 下拉菜单 -->
<div v-if="showGlobalModeDropdown" class="absolute top-full left-0 right-0 mt-1 bg-white border border-slate-200 rounded-md shadow-lg z-50">
<div
v-for="mode in globalModes"
:key="mode.value"
:value="mode.value"
@click="selectGlobalMode(mode.value)"
class="px-3 py-2 text-sm text-slate-700 hover:bg-slate-100 cursor-pointer"
:class="{ 'bg-slate-100': mode.value === currentGlobalMode }"
>
{{ mode.label }} - {{ mode.description }}
</option>
</select>
{{ mode.label }}
</div>
<div class="flex flex-row gap-2 items-center">
<label class="label">
<span class="label-text text-sm">通道组</span>
</div>
</div>
<p class="flex items-center text-xs text-slate-400">
{{ currentGlobalModeDescription }}
</p>
</div>
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased">
通道组
</label>
<select
v-model="currentChannelDiv"
@change="setChannelDiv(currentChannelDiv)"
class="select select-sm select-bordered"
<div class="relative w-[200px]">
<button
tabindex="0"
type="button"
class="flex items-center gap-4 justify-between h-max outline-none focus:outline-none bg-transparent ring-transparent border border-slate-200 transition-all duration-300 ease-in disabled:opacity-50 disabled:pointer-events-none select-none text-start text-sm rounded-md py-2 px-2.5 ring shadow-sm hover:border-slate-800 hover:ring-slate-800/10 focus:border-slate-800 focus:ring-slate-800/10 w-full"
@click="toggleChannelDivDropdown"
:aria-expanded="showChannelDivDropdown"
aria-haspopup="listbox"
role="combobox"
>
<option
<span>{{ currentChannelDivLabel }}</span>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-[1em] w-[1em] translate-x-0.5 stroke-[1.5]">
<path d="M17 8L12 3L7 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M17 16L12 21L7 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<input readonly style="display:none" :value="currentChannelDiv" />
<!-- 下拉菜单 -->
<div v-if="showChannelDivDropdown" class="absolute top-full left-0 right-0 mt-1 bg-white border border-slate-200 rounded-md shadow-lg z-50">
<div
v-for="option in channelDivOptions"
:key="option.value"
:value="option.value"
@click="selectChannelDiv(option.value)"
class="px-3 py-2 text-sm text-slate-700 hover:bg-slate-100 cursor-pointer"
:class="{ 'bg-slate-100': option.value === currentChannelDiv }"
>
{{ option.label }}
</option>
</select>
</div>
<div class="flex flex-row gap-2 items-center">
<label class="label">
<span class="label-text text-sm">捕获深度</span>
</div>
</div>
<p class="flex items-center text-xs text-slate-400">
{{ currentChannelDivDescription }}
</p>
</div>
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased">
捕获深度
</label>
<select
v-model="captureLength"
class="select select-sm select-bordered"
<div class="relative w-[200px]">
<button
@click="decreaseCaptureLength"
class="absolute right-9 top-1 rounded-md border border-transparent p-1.5 text-center text-sm transition-all hover:bg-slate-100 focus:bg-slate-100 active:bg-slate-100 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
:disabled="captureLength <= CAPTURE_LENGTH_MIN"
>
<option
v-for="option in captureLengthOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<div class="flex flex-row gap-2 items-center">
<label class="label">
<span class="label-text text-sm">预捕获深度</span>
</label>
<select
v-model="preCaptureLength"
class="select select-sm select-bordered"
>
<option
v-for="option in preCaptureLengthOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
<!-- 右侧操作按钮 -->
<div class="flex flex-row gap-2">
<button @click="resetConfiguration" class="btn btn-outline btn-sm">
重置配置
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4">
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
<input
v-model.number="captureLength"
@change="handleCaptureLengthChange"
type="number"
:min="CAPTURE_LENGTH_MIN"
:max="CAPTURE_LENGTH_MAX"
class="w-full bg-transparent placeholder:text-sm border border-slate-200 rounded-md pl-3 pr-20 py-2 transition duration-300 ease focus:outline-none focus:border-slate-400 ring ring-transparent hover:ring-slate-800/10 focus:ring-slate-800/10 hover:border-slate-800 shadow-sm focus:shadow appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
:placeholder="CAPTURE_LENGTH_MIN.toString()"
/>
<button
@click="increaseCaptureLength"
class="absolute right-1 top-1 rounded-md border border-transparent p-1.5 text-center text-sm transition-all hover:bg-slate-100 focus:bg-slate-100 active:bg-slate-100 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
:disabled="captureLength >= CAPTURE_LENGTH_MAX"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4">
<path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
</svg>
</button>
</div>
<p class="flex items-center text-xs text-slate-400">
范围: {{ CAPTURE_LENGTH_MIN.toLocaleString() }} - {{ CAPTURE_LENGTH_MAX.toLocaleString() }}
</p>
</div>
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased">
预捕获深度
</label>
<div class="relative w-[200px]">
<button
@click="decreasePreCaptureLength"
class="absolute right-9 top-1 rounded-md border border-transparent p-1.5 text-center text-sm transition-all hover:bg-slate-100 focus:bg-slate-100 active:bg-slate-100 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
:disabled="preCaptureLength <= PRE_CAPTURE_LENGTH_MIN"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4">
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
<input
v-model.number="preCaptureLength"
@change="handlePreCaptureLengthChange"
type="number"
:min="PRE_CAPTURE_LENGTH_MIN"
:max="Math.max(0, captureLength - 1)"
class="w-full bg-transparent placeholder:text-sm border border-slate-200 rounded-md pl-3 pr-20 py-2 transition duration-300 ease focus:outline-none focus:border-slate-400 ring ring-transparent hover:ring-slate-800/10 focus:ring-slate-800/10 hover:border-slate-800 shadow-sm focus:shadow appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
:placeholder="PRE_CAPTURE_LENGTH_MIN.toString()"
/>
<button
@click="increasePreCaptureLength"
class="absolute right-1 top-1 rounded-md border border-transparent p-1.5 text-center text-sm transition-all hover:bg-slate-100 focus:bg-slate-100 active:bg-slate-100 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
:disabled="preCaptureLength >= Math.max(0, captureLength - 1)"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4">
<path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
</svg>
</button>
</div>
<p class="flex items-center text-xs text-slate-400">
范围: {{ PRE_CAPTURE_LENGTH_MIN }} - {{ Math.max(0, captureLength - 1) }}
</p>
</div>
<div class="flex flex-col gap-2">
<label class="block text-sm font-semibold antialiased">
重置配置
</label>
<div class="relative w-[200px]">
<button
@click="resetConfiguration"
class="w-10 h-10 bg-transparent text-red-600 text-sm border border-red-200 rounded-md py-2 px-2.5 transition duration-300 ease ring ring-transparent hover:ring-red-600/10 focus:ring-red-600/10 hover:border-red-600 shadow-sm focus:shadow flex items-center justify-center"
type="button"
title="重置配置"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<p class="flex items-center text-xs text-slate-400">
恢复所有设置到默认值
</p>
</div>
</div>
</div>
<!-- 通道列表 -->
@@ -177,6 +270,7 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from "vue";
import { useRequiredInjection } from "@/utils/Common";
import { useLogicAnalyzerState } from "./LogicAnalyzerManager";
@@ -193,10 +287,121 @@ const {
operators,
signalValues,
channelDivOptions,
captureLengthOptions,
preCaptureLengthOptions,
CAPTURE_LENGTH_MIN,
CAPTURE_LENGTH_MAX,
PRE_CAPTURE_LENGTH_MIN,
validateCaptureLength,
validatePreCaptureLength,
setCaptureLength,
setPreCaptureLength,
setChannelDiv,
setGlobalMode,
resetConfiguration,
} = useRequiredInjection(useLogicAnalyzerState);
// 下拉菜单状态
const showGlobalModeDropdown = ref(false);
const showChannelDivDropdown = ref(false);
// 处理捕获深度变化
const handleCaptureLengthChange = () => {
setCaptureLength(captureLength.value);
};
// 处理预捕获深度变化
const handlePreCaptureLengthChange = () => {
setPreCaptureLength(preCaptureLength.value);
};
// 增加捕获深度
const increaseCaptureLength = () => {
const newValue = Math.min(captureLength.value + 1024, CAPTURE_LENGTH_MAX);
setCaptureLength(newValue);
};
// 减少捕获深度
const decreaseCaptureLength = () => {
const newValue = Math.max(captureLength.value - 1024, CAPTURE_LENGTH_MIN);
setCaptureLength(newValue);
};
// 增加预捕获深度
const increasePreCaptureLength = () => {
const maxValue = Math.max(0, captureLength.value - 1);
const newValue = Math.min(preCaptureLength.value + 64, maxValue);
setPreCaptureLength(newValue);
};
// 减少预捕获深度
const decreasePreCaptureLength = () => {
const newValue = Math.max(preCaptureLength.value - 64, PRE_CAPTURE_LENGTH_MIN);
setPreCaptureLength(newValue);
};
// 计算属性:获取当前全局模式的标签
const currentGlobalModeLabel = computed(() => {
const mode = globalModes.find(m => m.value === currentGlobalMode.value);
return mode ? mode.label : '';
});
// 计算属性:获取当前全局模式的描述
const currentGlobalModeDescription = computed(() => {
const mode = globalModes.find(m => m.value === currentGlobalMode.value);
return mode ? mode.description : '';
});
// 计算属性:获取当前通道组的标签
const currentChannelDivLabel = computed(() => {
const option = channelDivOptions.find(opt => opt.value === currentChannelDiv.value);
return option ? option.label : '';
});
// 计算属性:获取当前通道组的描述
const currentChannelDivDescription = computed(() => {
const option = channelDivOptions.find(opt => opt.value === currentChannelDiv.value);
return option ? option.description : '';
});
// 全局模式下拉菜单相关函数
const toggleGlobalModeDropdown = () => {
showGlobalModeDropdown.value = !showGlobalModeDropdown.value;
if (showGlobalModeDropdown.value) {
showChannelDivDropdown.value = false;
}
};
const selectGlobalMode = (mode: any) => {
setGlobalMode(mode);
showGlobalModeDropdown.value = false;
};
// 通道组下拉菜单相关函数
const toggleChannelDivDropdown = () => {
showChannelDivDropdown.value = !showChannelDivDropdown.value;
if (showChannelDivDropdown.value) {
showGlobalModeDropdown.value = false;
}
};
const selectChannelDiv = (value: number) => {
setChannelDiv(value);
showChannelDivDropdown.value = false;
};
// 点击其他地方关闭下拉菜单
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.relative')) {
showGlobalModeDropdown.value = false;
showChannelDivDropdown.value = false;
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>

View File

@@ -61,13 +61,6 @@
<Settings class="w-5 h-5" />
触发设置
</div>
<!-- 配置摘要 -->
<div class="flex items-center gap-4 text-sm text-gray-500">
<span>{{ analyzer.enabledChannelCount.value }}/32 通道</span>
<span>捕获: {{ analyzer.captureLength.value }}</span>
<span>预捕获: {{ analyzer.preCaptureLength.value }}</span>
<span>{{ analyzer.globalModes.find(m => m.value === analyzer.currentGlobalMode.value)?.label || '未知' }}</span>
</div>
</div>
<div class="flex items-center gap-4">
<!-- 状态指示 -->