Initial commit

This commit is contained in:
2026-07-03 16:31:37 +07:00
commit 899c7c637d
1939 changed files with 641750 additions and 0 deletions

View File

@@ -0,0 +1,296 @@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Shared.Enums
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject ISnackbar Snackbar
@inject MapManagerApiService MapApiService
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Class="mr-2" />
Create Robot Model
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="request.ModelName"
Label="Model Name *"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("ModelName")" />
<MudNumericField T="double" @bind-Value="request.Length"
Label="Length (m) *"
Variant="Variant.Outlined"
Min="0.01"
Max="1000"
Step="0.01"
ErrorText="@GetValidationError("Length")" />
<MudNumericField T="double" @bind-Value="request.Width"
Label="Width (m) *"
Variant="Variant.Outlined"
Min="0.01"
Max="1000"
Step="0.01"
ErrorText="@GetValidationError("Width")" />
<MudNumericField T="double" @bind-Value="request.NavigationPointX"
Label="Navigation Point X (m) *"
Variant="Variant.Outlined"
Step="0.01"
ErrorText="@GetValidationError("NavigationPointX")" />
<MudNumericField T="double" @bind-Value="request.NavigationPointY"
Label="Navigation Point Y (m) *"
Variant="Variant.Outlined"
Step="0.01"
ErrorText="@GetValidationError("NavigationPointY")" />
<MudSelect @bind-Value="request.NavigationType"
Label="Navigation Type *"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("NavigationType")">
@foreach (NavigationType navType in Enum.GetValues<NavigationType>())
{
<MudSelectItem Value="@navType">@navType.ToString()</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="request.VehicleTypeId"
Label="Vehicle Type (Optional)"
Variant="Variant.Outlined"
T="Guid ?"
Clearable="true"
Disabled="@isLoadingVehicleTypes"
ErrorText="@GetValidationError("VehicleTypeId")">
@if (isLoadingVehicleTypes)
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">Loading vehicle types...</MudSelectItem>
}
else if (vehicleTypes != null && vehicleTypes.Any())
{
<MudSelectItem Value="@((Guid?)null)">None</MudSelectItem>
@foreach (var vehicleType in vehicleTypes)
{
<MudSelectItem Value="@((Guid?)vehicleType.Id)">@($"{vehicleType.VehicleTypeId} - {vehicleType.VehicleTypeName}")</MudSelectItem>
}
}
else
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">No vehicle types available</MudSelectItem>
}
</MudSelect>
<MudDivider />
<MudText Typo="Typo.subtitle2">Image Upload (Optional)</MudText>
<MudFileUpload T="IBrowserFile"
@bind-Files="selectedFile"
Accept=".png"
MaximumFileCount="1">
<CustomContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
OnClick="@context.OpenFilePickerAsync">
Upload Image (PNG)
</MudButton>
</CustomContent>
</MudFileUpload>
@if (selectedFile != null)
{
<MudChip T="string" Color="Color.Success" Icon="@Icons.Material.Filled.AttachFile">
@selectedFile.Name (@FormatFileSize(selectedFile.Size))
</MudChip>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isCreating">
@if (isCreating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Creating...</span>
}
else
{
<span>Create</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public RobotModelApiService ApiService { get; set; } = null!;
private CreateRobotModelRequest request = new()
{
NavigationType = NavigationType.Differential, // Default value
ImageWidth = 100, // Default value, will be updated if image is uploaded
ImageHeight = 100 // Default value, will be updated if image is uploaded
};
private IBrowserFile? selectedFile;
private Dictionary<string, string> validationErrors = new();
private bool isCreating = false;
private List<VehicleTypeDto>? vehicleTypes;
private bool isLoadingVehicleTypes = false;
protected override async Task OnInitializedAsync()
{
await LoadVehicleTypesAsync();
}
private async Task LoadVehicleTypesAsync()
{
isLoadingVehicleTypes = true;
StateHasChanged();
try
{
vehicleTypes = await MapApiService.GetVehicleTypesAsync(isActive: true);
}
catch (Exception ex)
{
Snackbar.Add($"Error loading vehicle types: {ex.Message}", Severity.Warning);
vehicleTypes = new List<VehicleTypeDto>();
}
finally
{
isLoadingVehicleTypes = false;
StateHasChanged();
}
}
private bool Validate()
{
validationErrors.Clear();
if (string.IsNullOrWhiteSpace(request.ModelName))
{
validationErrors["ModelName"] = "Model Name is required";
}
else if (request.ModelName.Length > 256)
{
validationErrors["ModelName"] = "Model Name must not exceed 256 characters";
}
if (request.Length <= 0)
{
validationErrors["Length"] = "Length must be greater than 0";
}
else if (request.Length > 1000)
{
validationErrors["Length"] = "Length must not exceed 1000 meters";
}
if (request.Width <= 0)
{
validationErrors["Width"] = "Width must be greater than 0";
}
else if (request.Width > 1000)
{
validationErrors["Width"] = "Width must not exceed 1000 meters";
}
// ImageWidth and ImageHeight are set to default values (100) when request is initialized
// They will be updated by server if image is uploaded
// No need to validate them here as they're always set to valid values (100)
// NavigationPointX and NavigationPointY can be 0 or any decimal value
// They are required fields but can be 0, so no validation needed here
// Server-side validation will handle range checks if needed
return validationErrors.Count == 0;
}
private string? GetValidationError(string fieldName)
{
return validationErrors.TryGetValue(fieldName, out var error) ? error : null;
}
private string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!Validate())
{
var errorMessages = string.Join(", ", validationErrors.Values);
Snackbar.Add($"Please fix validation errors: {errorMessages}", Severity.Warning);
StateHasChanged();
return;
}
isCreating = true;
StateHasChanged();
try
{
// ImageWidth and ImageHeight are already set to default values (100) in request initialization
// If image is provided, server will update these values after extracting dimensions
// If no image, default values (100x100) will be used
// Create robot model first
var created = await ApiService.CreateAsync(request);
// Upload image if provided
if (selectedFile != null)
{
try
{
using var stream = selectedFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB
await ApiService.UploadImageAsync(created.Id, stream, selectedFile.Name);
}
catch (Exception imageEx)
{
// Log image upload error but don't fail the entire operation
// The robot model was created successfully, image can be uploaded later
Snackbar.Add($"Robot model created but image upload failed: {imageEx.Message}", Severity.Warning);
}
}
Snackbar.Add($"Robot model '{request.ModelName}' created successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(created));
}
catch (HttpRequestException httpEx)
{
var errorMessage = httpEx.Message;
if (httpEx.Data.Contains("Response"))
{
errorMessage = $"Network error: {httpEx.Message}";
}
Snackbar.Add($"Error creating robot model: {errorMessage}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Error creating robot model: {ex.Message}", Severity.Error);
}
finally
{
isCreating = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,123 @@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Class="mr-2" />
Delete Robot Model
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudText Typo="Typo.body1">
Are you sure you want to delete the robot model <strong>@RobotModel.ModelName</strong>?
</MudText>
@if (usageInfo != null)
{
@if (usageInfo.RobotCount > 0)
{
<MudAlert Severity="Severity.Error">
<MudText>This robot model is currently being used by <strong>@usageInfo.RobotCount</strong> robot(s).</MudText>
<MudText>You must delete or reassign all robots using this model before you can delete it.</MudText>
</MudAlert>
}
else
{
<MudAlert Severity="Severity.Warning">
<MudText>This action cannot be undone.</MudText>
</MudAlert>
}
}
else if (isLoadingUsageInfo)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Error"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@(isDeleting || (usageInfo != null && !usageInfo.CanDelete))">
@if (isDeleting)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Deleting...</span>
}
else
{
<span>Delete</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public RobotModelApiService ApiService { get; set; } = null!;
[Parameter] public RobotModelDto RobotModel { get; set; } = null!;
private RobotModelUsageInfoDto? usageInfo;
private bool isLoadingUsageInfo = true;
private bool isDeleting = false;
protected override async Task OnInitializedAsync()
{
await LoadUsageInfoAsync();
}
private async Task LoadUsageInfoAsync()
{
isLoadingUsageInfo = true;
try
{
usageInfo = await ApiService.GetUsageInfoAsync(RobotModel.Id);
}
catch (Exception ex)
{
Snackbar.Add($"Error loading usage info: {ex.Message}", Severity.Error);
}
finally
{
isLoadingUsageInfo = false;
StateHasChanged();
}
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (usageInfo != null && !usageInfo.CanDelete)
{
Snackbar.Add("Cannot delete robot model that is in use", Severity.Warning);
return;
}
isDeleting = true;
StateHasChanged();
try
{
await ApiService.DeleteAsync(RobotModel.Id);
Snackbar.Add($"Robot model '{RobotModel.ModelName}' deleted successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(true));
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting robot model: {ex.Message}", Severity.Error);
}
finally
{
isDeleting = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,259 @@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Shared.Enums
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject ISnackbar Snackbar
@inject MapManagerApiService MapApiService
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6">
<MudIcon Icon="@Icons.Material.Filled.Edit" Color="Color.Primary" Class="mr-2" />
Edit Robot Model
</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="request.ModelName"
Label="Model Name"
Variant="Variant.Outlined"
ErrorText="@GetValidationError("ModelName")" />
<MudNumericField T="double?" @bind-Value="request.Length"
Label="Length (m)"
Variant="Variant.Outlined"
Min="0.01"
Max="1000"
Step="0.01"
ErrorText="@GetValidationError("Length")" />
<MudNumericField T="double?" @bind-Value="request.Width"
Label="Width (m)"
Variant="Variant.Outlined"
Min="0.01"
Max="1000"
Step="0.01"
ErrorText="@GetValidationError("Width")" />
<MudNumericField T="double?" @bind-Value="request.NavigationPointX"
Label="Navigation Point X (m)"
Variant="Variant.Outlined"
Step="0.01"
ErrorText="@GetValidationError("NavigationPointX")" />
<MudNumericField T="double?" @bind-Value="request.NavigationPointY"
Label="Navigation Point Y (m)"
Variant="Variant.Outlined"
Step="0.01"
ErrorText="@GetValidationError("NavigationPointY")" />
<MudSelect @bind-Value="request.NavigationType"
Label="Navigation Type"
Variant="Variant.Outlined"
T="NavigationType?"
ErrorText="@GetValidationError("NavigationType")" ReadOnly>
@foreach (NavigationType navType in Enum.GetValues<NavigationType>())
{
<MudSelectItem Value="@((NavigationType?)navType)">@navType.ToString()</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="request.VehicleTypeId"
Label="Vehicle Type (Optional)"
Variant="Variant.Outlined"
T="Guid?"
Clearable="true"
Disabled="@isLoadingVehicleTypes"
ErrorText="@GetValidationError("VehicleTypeId")">
@if (isLoadingVehicleTypes)
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">Loading vehicle types...</MudSelectItem>
}
else if (vehicleTypes != null && vehicleTypes.Any())
{
<MudSelectItem Value="@((Guid?)null)">None</MudSelectItem>
@foreach (var vehicleType in vehicleTypes)
{
<MudSelectItem Value="@((Guid?)vehicleType.Id)">@($"{vehicleType.VehicleTypeId} - {vehicleType.VehicleTypeName}")</MudSelectItem>
}
}
else
{
<MudSelectItem Value="@((Guid?)null)" Disabled="true">No vehicle types available</MudSelectItem>
}
</MudSelect>
<MudDivider />
<MudText Typo="Typo.subtitle2">Image Upload (Optional - Leave empty to keep current image)</MudText>
<MudFileUpload T="IBrowserFile"
@bind-Files="selectedFile"
Accept=".png"
MaximumFileCount="1">
<CustomContent>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CloudUpload"
OnClick="@context.OpenFilePickerAsync">
Upload New Image (PNG)
</MudButton>
</CustomContent>
</MudFileUpload>
@if (selectedFile != null)
{
<MudChip T="string" Color="Color.Success" Icon="@Icons.Material.Filled.AttachFile">
@selectedFile.Name (@FormatFileSize(selectedFile.Size))
</MudChip>
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Submit"
Disabled="@isUpdating">
@if (isUpdating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Updating...</span>
}
else
{
<span>Update</span>
}
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance? MudDialog { get; set; }
[Parameter] public RobotModelApiService ApiService { get; set; } = null!;
[Parameter] public RobotModelDto RobotModel { get; set; } = null!;
private UpdateRobotModelRequest request = new();
private IBrowserFile? selectedFile;
private Dictionary<string, string> validationErrors = new();
private bool isUpdating = false;
private List<VehicleTypeDto>? vehicleTypes;
private bool isLoadingVehicleTypes = false;
protected override async Task OnInitializedAsync()
{
// Pre-fill with existing data
request.ModelName = RobotModel.ModelName;
request.Length = RobotModel.Length;
request.Width = RobotModel.Width;
request.NavigationPointX = RobotModel.NavigationPointX;
request.NavigationPointY = RobotModel.NavigationPointY;
request.NavigationType = RobotModel.NavigationType; // This is nullable, but we set it to the actual value
request.VehicleTypeId = RobotModel.VehicleTypeId;
await LoadVehicleTypesAsync();
}
private async Task LoadVehicleTypesAsync()
{
isLoadingVehicleTypes = true;
StateHasChanged();
try
{
vehicleTypes = await MapApiService.GetVehicleTypesAsync(isActive: true);
}
catch (Exception ex)
{
Snackbar.Add($"Error loading vehicle types: {ex.Message}", Severity.Warning);
vehicleTypes = new List<VehicleTypeDto>();
}
finally
{
isLoadingVehicleTypes = false;
StateHasChanged();
}
}
private bool Validate()
{
validationErrors.Clear();
if (!string.IsNullOrWhiteSpace(request.ModelName) && request.ModelName.Length > 256)
{
validationErrors["ModelName"] = "Model Name must be 256 characters or less";
}
if (request.Length.HasValue && request.Length.Value <= 0)
{
validationErrors["Length"] = "Length must be greater than 0";
}
if (request.Width.HasValue && request.Width.Value <= 0)
{
validationErrors["Width"] = "Width must be greater than 0";
}
return validationErrors.Count == 0;
}
private string? GetValidationError(string fieldName)
{
return validationErrors.TryGetValue(fieldName, out var error) ? error : null;
}
private string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
private void Cancel()
{
MudDialog?.Cancel();
}
private async Task Submit()
{
if (!Validate())
{
Snackbar.Add("Please fix validation errors", Severity.Warning);
StateHasChanged();
return;
}
isUpdating = true;
StateHasChanged();
try
{
// Update robot model
var updated = await ApiService.UpdateAsync(RobotModel.Id, request);
// Upload new image if provided
if (selectedFile != null)
{
using var stream = selectedFile.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB
await ApiService.UploadImageAsync(updated.Id, stream, selectedFile.Name);
}
Snackbar.Add($"Robot model '{updated.ModelName}' updated successfully", Severity.Success);
MudDialog?.Close(DialogResult.Ok(updated));
}
catch (Exception ex)
{
Snackbar.Add($"Error updating robot model: {ex.Message}", Severity.Error);
}
finally
{
isUpdating = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,144 @@
@*
Component: RobotModelDetailsPanel
Purpose: Displays detailed information about a selected robot model.
Shows image preview with navigation point, dimensions, and usage statistics.
*@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.FleetManager.Client.Components.RobotModelManager.Dialogs
@using RobotNet10.MapEditor.Services.API
@using RobotNet10.MapEditor.Shared.DTOs.VehicleType
@inject IDialogService DialogService
@inject MapManagerApiService MapApiService
<!-- Image Preview -->
<RobotModelImagePreview RobotModel="@RobotModel" />
<!-- Details -->
<MudStack Class="mt-2">
<MudSimpleTable>
<tbody>
<tr>
<td><strong>Model Name:</strong></td>
<td>@RobotModel.ModelName</td>
</tr>
<tr>
<td><strong>Navigation Type:</strong></td>
<td>@RobotModel.NavigationType</td>
</tr>
<tr>
<td><strong>Vehicle Type:</strong></td>
<td>
@if (RobotModel.VehicleTypeId.HasValue)
{
@if (vehicleTypeName != null)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">@vehicleTypeName</MudChip>
}
else if (isLoadingVehicleType)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@RobotModel.VehicleTypeId.Value.ToString("N")</MudChip>
}
}
else
{
<MudText Typo="Typo.body2" Style="color: gray;">Not assigned</MudText>
}
</td>
</tr>
<tr>
<td><strong>Dimensions:</strong></td>
<td>@($"{RobotModel.Length}m × {RobotModel.Width}m")</td>
</tr>
<tr>
<td><strong>Image Size:</strong></td>
<td>@($"{RobotModel.ImageWidth} × {RobotModel.ImageHeight} px")</td>
</tr>
<tr>
<td><strong>Navigation Point:</strong></td>
<td>@($"X: {RobotModel.NavigationPointX}m, Y: {RobotModel.NavigationPointY}m")</td>
</tr>
<tr>
<td><strong>Robot Count:</strong></td>
<td>@RobotModel.RobotCount</td>
</tr>
<tr>
<td><strong>Created:</strong></td>
<td>@RobotModel.CreatedDate.ToString("g")</td>
</tr>
@if (RobotModel.UpdatedDate.HasValue)
{
<tr>
<td><strong>Updated:</strong></td>
<td>@RobotModel.UpdatedDate.Value.ToString("g")</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudStack>
@code {
[Parameter]
public RobotModelDto RobotModel { get; set; } = null!;
private string? vehicleTypeName;
private bool isLoadingVehicleType = false;
protected override async Task OnInitializedAsync()
{
if (RobotModel.VehicleTypeId.HasValue)
{
await LoadVehicleTypeNameAsync();
}
}
protected override async Task OnParametersSetAsync()
{
if (RobotModel.VehicleTypeId.HasValue)
{
await LoadVehicleTypeNameAsync();
}
else
{
vehicleTypeName = null;
}
}
private async Task LoadVehicleTypeNameAsync()
{
if (!RobotModel.VehicleTypeId.HasValue)
{
vehicleTypeName = null;
return;
}
isLoadingVehicleType = true;
StateHasChanged();
try
{
var vehicleType = await MapApiService.GetVehicleTypeAsync(RobotModel.VehicleTypeId.Value);
if (vehicleType != null)
{
vehicleTypeName = $"{vehicleType.VehicleTypeId} - {vehicleType.VehicleTypeName}";
}
else
{
vehicleTypeName = null;
}
}
catch (Exception)
{
vehicleTypeName = null;
}
finally
{
isLoadingVehicleType = false;
StateHasChanged();
}
}
}

View File

@@ -0,0 +1,162 @@
@*
Component: RobotModelImagePreview
Purpose: Displays robot model image with navigation point overlay visualization.
The navigation point is shown as arrows (X+ in red, Y+ in green) and a blue marker.
Uses SVG viewBox to automatically scale overlay to match displayed image size.
*@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@inject RobotModelApiService ApiService
@if (imageBase64 != null)
{
<div style="position: relative; width: 100%; max-width: 100%; max-height: 250px; overflow: hidden; display: flex; align-items: center; justify-content: center; border-radius: 4px;">
<div style="position: relative; display: inline-block; max-width: 100%; max-height: 250px;">
<img @ref="imageElement"
src="data:image/png;base64,@imageBase64"
alt="Robot Model Image"
style="max-width: 100%; max-height: 250px; height: auto; width: auto; display: block; object-fit: contain;"
@onload="OnImageLoaded" />
<!-- SVG Overlay for Navigation Point -->
@if (RobotModel.ImageWidth > 0 && RobotModel.ImageHeight > 0)
{
<svg style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"
viewBox="0 0 @RobotModel.ImageWidth @RobotModel.ImageHeight"
preserveAspectRatio="xMidYMid meet">
<!-- X-axis arrow (right) - Red arrow pointing right (X+) -->
<line x1="@svgX"
y1="@svgY"
x2="@(svgX + arrowLength)"
y2="@svgY"
stroke="red"
stroke-width="5"
marker-end="url(#arrowhead-x-@uniqueId)" />
<!-- Y-axis arrow (up) - Green arrow pointing up (Y+) -->
<line x1="@svgX"
y1="@svgY"
x2="@svgX"
y2="@(svgY - arrowLength)"
stroke="green"
stroke-width="5"
marker-end="url(#arrowhead-y-@uniqueId)" />
<!-- Navigation point marker -->
<circle cx="@svgX"
cy="@svgY"
r="10"
fill="blue"
stroke="white"
stroke-width="2" />
<!-- Arrow markers definition -->
<defs>
<marker id="arrowhead-x-@uniqueId"
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
markerUnits="strokeWidth">
<polygon points="0 0, 10 3, 0 6" fill="red" />
</marker>
<marker id="arrowhead-y-@uniqueId"
markerWidth="10"
markerHeight="10" efX="9"
refY="3"
orient="auto"
markerUnits="strokeWidth">
<polygon points="0 0, 10 3, 0 6" fill="green" />
</marker>
</defs>
</svg>
}
</div>
</div>
}
else
{
<MudAlert Severity="Severity.Info">No image available</MudAlert>
}
@code {
[Parameter]
public RobotModelDto RobotModel { get; set; } = null!;
private string? imageBase64;
private double svgX;
private double svgY;
private double arrowLength;
private ElementReference imageElement;
private string uniqueId = Guid.NewGuid().ToString("N")[..8]; // Unique ID for SVG markers
protected override async Task OnInitializedAsync()
{
await LoadImageAsync();
}
protected override async Task OnParametersSetAsync()
{
await LoadImageAsync();
}
private void OnImageLoaded()
{
CalculateNavigationPoint();
}
private void CalculateNavigationPoint()
{
if (RobotModel.ImageWidth > 0 && RobotModel.ImageHeight > 0 && RobotModel.Length > 0 && RobotModel.Width > 0)
{
// Calculate scale: pixels per meter in original image
// This converts from robot coordinate system (meters) to image coordinate system (pixels)
var scaleX = RobotModel.ImageWidth / RobotModel.Length;
var scaleY = RobotModel.ImageHeight / RobotModel.Width;
// Navigation point coordinates are relative to the bottom-left corner of the image (in meters)
// Image coordinate system: (0,0) is at bottom-left corner
// X-axis: left to right (0 to ImageWidth)
// Y-axis: bottom to top (0 to ImageHeight in robot coords, but SVG Y increases downward)
// Convert navigation point from meters to pixels
// NavigationPointX: distance from left edge (in meters)
// NavigationPointY: distance from bottom edge (in meters)
var navX = RobotModel.NavigationPointX * scaleX;
var navY = RobotModel.NavigationPointY * scaleY;
// Final position in SVG coordinates (using original image coordinate system)
// SVG viewBox will automatically scale to match the displayed image size
// X: from left edge (0 is left, ImageWidth is right)
svgX = navX;
// Y: from bottom edge (0 is bottom in robot coords, but SVG Y=0 is top, Y=ImageHeight is bottom)
// So we need to invert: svgY = ImageHeight - navY
svgY = RobotModel.ImageHeight - navY;
// Arrow length: 15% of the smaller dimension for better visibility
arrowLength = Math.Min(RobotModel.ImageWidth, RobotModel.ImageHeight) * 0.15;
StateHasChanged();
}
}
private async Task LoadImageAsync()
{
try
{
imageBase64 = await ApiService.GetImageAsync(RobotModel.Id);
if (imageBase64 != null)
{
// Calculate navigation point when image loads
CalculateNavigationPoint();
}
}
catch (Exception)
{
imageBase64 = null;
}
}
}

View File

@@ -0,0 +1,252 @@
@using RobotNet10.FleetManager.Shared.DTOs.RobotModel
@using RobotNet10.FleetManager.Client.Services
@using RobotNet10.FleetManager.Client.Components.RobotModelManager.Dialogs
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<style>
.selected {
background-color: #1E88E5 !important;
}
.selected > td {
color: white !important;
}
.selected > td .mud-input {
color: white !important;
}
</style>
<MudPaper Class="pa-4" Elevation="1">
@if (isLoading)
{
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="my-4">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Medium" />
<MudText Typo="Typo.body2">Loading robot models...</MudText>
</MudStack>
}
else if (robotModels.Count == 0)
{
<MudPaper Class="pa-4" Elevation="0">
<MudText Typo="Typo.body1" Align="Align.Center" Class="mt-4">
No robot models found. Click "Add Robot Model" to create one.
</MudText>
</MudPaper>
}
else
{
<MudTable Items="@robotModels"
@ref="@table"
T="RobotModelDto"
Hover="true"
Dense="true"
FixedHeader="true"
SelectOnRowClick=true
Elevation="0"
Height="calc(100vh - 254px)"
RowClass="cursor-pointer"
RowClassFunc="@SelectedRowClassFunc"
OnRowClick="RowClickEvent"
SelectedItemChanged="HandleSelectedItemChanged">
<HeaderContent>
<MudTh>Model Name</MudTh>
<MudTh>Navigation Type</MudTh>
<MudTh>Dimensions</MudTh>
<MudTh>Robot Count</MudTh>
<MudTh>Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Model Name">
<MudText Typo="Typo.body2">@context.ModelName</MudText>
</MudTd>
<MudTd DataLabel="Navigation Type">
<MudChip T="string" Size="Size.Small" Color="Color.Primary">
@context.NavigationType.ToString()
</MudChip>
</MudTd>
<MudTd DataLabel="Dimensions">
<MudText Typo="Typo.body2">@($"{context.Length}m × {context.Width}m")</MudText>
</MudTd>
<MudTd DataLabel="Robot Count">
<MudText Typo="Typo.body2">@context.RobotCount</MudText>
</MudTd>
<MudTd DataLabel="Actions">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => HandleEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => HandleDelete(context))" />
</MudTd>
</RowTemplate>
<PagerContent>
<div class="d-flex w-100 flex-row-reverse">
<MudTablePager Style="width: 100%;" PageSizeOptions="new[] { 25, 50, 100 }" />
</div>
</PagerContent>
</MudTable>
}
</MudPaper>
@code {
[Parameter]
public RobotModelApiService ApiService { get; set; } = null!;
[Parameter]
public EventCallback<RobotModelDto?> SelectedRobotModelChanged { get; set; }
[Parameter]
public string SearchText { get; set; } = string.Empty;
private List<RobotModelDto> robotModels = new();
private MudTable<RobotModelDto>? table;
private bool isLoading = false;
private int selectedRowNumber = -1;
public RobotModelDto? SelectedRobotModel { get; set; }
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
public async Task LoadDataAsync()
{
isLoading = true;
StateHasChanged();
try
{
robotModels = await ApiService.GetAllAsync();
table?.ReloadServerData();
}
catch (HttpRequestException ex)
{
Snackbar.Add($"Network error loading robot models: {ex.Message}", Severity.Error);
robotModels = new List<RobotModelDto>();
}
catch (Exception ex)
{
Snackbar.Add($"Error loading robot models: {ex.Message}", Severity.Error);
robotModels = new List<RobotModelDto>();
}
finally
{
isLoading = false;
StateHasChanged();
}
}
public async Task SearchAsync(string query)
{
isLoading = true;
StateHasChanged();
try
{
if (string.IsNullOrWhiteSpace(query))
{
robotModels = await ApiService.GetAllAsync();
}
else
{
robotModels = await ApiService.SearchAsync(query);
}
table?.ReloadServerData();
}
catch (HttpRequestException ex)
{
Snackbar.Add($"Network error searching robot models: {ex.Message}", Severity.Error);
robotModels = new List<RobotModelDto>();
}
catch (Exception ex)
{
Snackbar.Add($"Error searching robot models: {ex.Message}", Severity.Error);
robotModels = new List<RobotModelDto>();
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private void RowClickEvent(TableRowClickEventArgs<RobotModelDto> tableRowClickEventArgs) { }
private string SelectedRowClassFunc(RobotModelDto element, int rowNumber)
{
if (selectedRowNumber == rowNumber && table?.SelectedItem != null && !table.SelectedItem.Equals(element))
{
return string.Empty;
}
else if (selectedRowNumber == rowNumber && table?.SelectedItem != null && table.SelectedItem.Equals(element))
{
return "selected";
}
else if (table?.SelectedItem != null && table.SelectedItem.Equals(element))
{
selectedRowNumber = rowNumber;
return "selected";
}
else
{
return string.Empty;
}
}
private void HandleSelectedItemChanged(RobotModelDto element)
{
SelectedRobotModel = element;
_ = SelectedRobotModelChanged.InvokeAsync(element);
}
private async Task HandleEdit(RobotModelDto model)
{
var parameters = new DialogParameters
{
["ApiService"] = ApiService,
["RobotModel"] = model
};
var dialog = await DialogService.ShowAsync<EditRobotModelDialog>("Edit Robot Model", parameters,
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await LoadDataAsync();
// Reload selected model if it was the one edited
if (SelectedRobotModel?.Id == model.Id)
{
var updated = await ApiService.GetByIdAsync(model.Id);
if (updated != null)
{
await SelectedRobotModelChanged.InvokeAsync(updated);
}
}
}
}
private async Task HandleDelete(RobotModelDto model)
{
var parameters = new DialogParameters
{
["ApiService"] = ApiService,
["RobotModel"] = model
};
var dialog = await DialogService.ShowAsync<DeleteRobotModelDialog>("Delete Robot Model", parameters);
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await LoadDataAsync();
// Clear selection if deleted model was selected
if (SelectedRobotModel?.Id == model.Id)
{
await SelectedRobotModelChanged.InvokeAsync(null);
}
}
}
}