RobotApp/RobotApp.Client/Pages/Home.razor
2025-12-20 17:57:54 +07:00

652 lines
32 KiB
Plaintext

@page "/"
@using System.Text.Json
@using System.Text.Json.Serialization
@using RobotApp.VDA5050.InstantAction
@using RobotApp.VDA5050.Order
@using RobotApp.VDA5050.Type
@using System.ComponentModel.DataAnnotations
@attribute [Authorize]
@rendermode InteractiveWebAssemblyNoPrerender
@inject IJSRuntime JS
@inject IDialogService DialogService
<MudMainContent Class="pa-0 ma-0">
<div style="height:100vh; overflow:hidden;">
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4" Style="max-width: 100%; height:100%; display:flex; flex-direction:column;">
<!-- TIÊU ĐỀ -->
<MudText Typo="Typo.h4" Align="Align.Center" Class="mb-6 flex-shrink-0">
🧾 VDA5050 Order Editor
</MudText>
<MudGrid Spacing="4" Class="flex-grow-1" Style="overflow:hidden;">
<!-- ================= CỘT TRÁI (50%) ================= -->
<MudItem xs="12" md="7" Class="d-flex flex-column h-100" Style="gap:16px;">
<!-- Nodes và Edges chia 1x2, mỗi phần có scroll nội bộ -->
<MudGrid Spacing="4" Class="flex-grow-1" Style="overflow:hidden;">
<!-- Nodes (trái) -->
<MudItem xs="12" md="6" Class="h-100">
<MudPaper Class="pa-4 h-100 d-flex flex-column" Elevation="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4 flex-shrink-0">
<MudText Typo="Typo.h6">📍 Nodes</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddNode">
Add Node
</MudButton>
</MudStack>
<!-- Cuộn nội bộ chỉ cho phần danh sách Nodes -->
<div class="flex-grow-1" style="overflow:auto;">
<MudExpansionPanels MultiExpansion="true">
@foreach (var node in Order.Nodes)
{
<MudExpansionPanel @key="node">
<!-- ===== HEADER ===== -->
<TitleContent>
<div class="d-flex align-center justify-space-between w-100">
<MudText Typo="Typo.subtitle1" Class="fw-bold">
@node.NodeId
</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Color="Color.Primary"
Size="Size.Small"
OnClick="@(() => OpenEditNodeDialog(node))"
StopPropagation="true" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => RemoveNode(node))"
StopPropagation="true" />
</div>
</TitleContent>
<!-- ===== BODY (BẮT BUỘC ChildContent) ===== -->
<ChildContent>
<MudGrid Spacing="3">
<MudItem xs="12">
<MudTextField @bind-Value="node.NodeId" Label="Node ID" />
</MudItem>
<MudItem xs="12">
<MudNumericField T="int"
@bind-Value="node.SequenceId"
Label="Sequence ID" />
</MudItem>
<MudItem xs="12">
<MudSwitch T="bool"
@bind-Checked="node.Released"
Label="Released" />
</MudItem>
<MudItem xs="6">
<MudNumericField T="double"
@bind-Value="node.NodePosition.X"
Label="X" />
</MudItem>
<MudItem xs="6">
<MudNumericField T="double"
@bind-Value="node.NodePosition.Y"
Label="Y" />
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="node.NodePosition.MapId"
Label="Map ID" />
</MudItem>
<MudItem xs="6">
<MudNumericField T="double"
@bind-Value="node.NodePosition.Theta"
Label="Theta (rad)" />
</MudItem>
<MudItem xs="6">
<MudNumericField T="double"
@bind-Value="node.NodePosition.AllowedDeviationXY"
Label="Allowed Dev XY" />
</MudItem>
<MudItem xs="6">
<MudNumericField T="double"
@bind-Value="node.NodePosition.AllowedDeviationTheta"
Label="Allowed Dev Theta" />
</MudItem>
<MudItem xs="12">
<MudDivider Class="my-4" />
<MudText Typo="Typo.subtitle1" Class="mb-3">
Actions
</MudText>
@foreach (var act in node.Actions)
{
<MudPaper Class="pa-3 mb-3" Outlined>
<MudGrid Spacing="3">
<MudItem xs="12">
<MudSelect T="string"
Label="Action Type"
@bind-Value="act.ActionType"
Dense="true"
Required="true">
@foreach (var at in Enum.GetValues<ActionType>())
{
<MudSelectItem Value="@at.ToString()">
@at
</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudSelect T="string"
@bind-Value="act.BlockingType"
Label="Blocking Type">
<MudSelectItem Value="@("NONE")">NONE</MudSelectItem>
<MudSelectItem Value="@("SOFT")">SOFT</MudSelectItem>
<MudSelectItem Value="@("HARD")">HARD</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudTextField @bind-Value="act.ActionId"
Label="Action ID" />
</MudItem>
</MudGrid>
<MudText Typo="Typo.caption" Class="mt-3 mb-2">
Action Parameters
</MudText>
@foreach (var p in act.ActionParameters.Cast<UiActionParameter>())
{
<MudGrid Class="mt-1">
<MudItem xs="6">
<MudTextField @bind-Value="p.Key" Label="Key" />
</MudItem>
<MudItem xs="6">
<MudTextField @bind-Value="p.ValueString" Label="Value" />
</MudItem>
<MudItem xs="2">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="@(() => RemoveActionParameter(act, p))" />
</MudItem>
</MudGrid>
}
<MudButton Size="Size.Small"
StartIcon="@Icons.Material.Filled.Add"
Class="mt-3"
OnClick="@(() => AddActionParameter(act))">
Add Parameter
</MudButton>
<MudDivider Class="my-3" />
<MudButton Size="Size.Small"
Color="Color.Error"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.Delete"
OnClick="@(() => RemoveAction(node, act))">
Remove Action
</MudButton>
</MudPaper>
}
<MudButton Size="Size.Small"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(() => AddAction(node))">
Add Action
</MudButton>
</MudItem>
</MudGrid>
</ChildContent>
</MudExpansionPanel>
}
</MudExpansionPanels>
</div>
</MudPaper>
</MudItem>
<!-- Edges (phải) -->
<MudItem xs="12" md="6" Class="h-100">
<MudPaper Class="pa-4 h-100 d-flex flex-column" Elevation="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4 flex-shrink-0">
<MudText Typo="Typo.h6">🔗 Edges</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="AddEdge">
Add Edge
</MudButton>
</MudStack>
<!-- Cuộn nội bộ chỉ cho phần danh sách Edges -->
<div class="flex-grow-1" style="overflow:auto;">
<MudExpansionPanels MultiExpansion="true">
@foreach (var edge in Order.Edges)
{
<MudExpansionPanel Text="@($"{edge.EdgeId} ({edge.StartNodeId} → {edge.EndNodeId})")" @key="edge">
<MudGrid Spacing="3">
<MudItem xs="12"><MudTextField @bind-Value="edge.EdgeId" Label="Edge ID" /></MudItem>
<MudItem xs="12">
<MudSelect T="string"
Label="Start Node"
Dense="true"
Required="true"
@bind-Value="edge.StartNodeId">
@foreach (var node in Order.Nodes)
{
<MudSelectItem Value="@node.NodeId">
@node.NodeId
</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudSelect T="string"
Label="End Node"
Dense="true"
Required="true"
@bind-Value="edge.EndNodeId">
@foreach (var node in Order.Nodes)
{
<MudSelectItem Value="@node.NodeId"
Disabled="@(node.NodeId == edge.StartNodeId)">
@node.NodeId
</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" Class="mt-4">
<MudButton Color="Color.Error" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Delete"
OnClick="@(() => RemoveEdge(edge))">Remove Edge</MudButton>
</MudItem>
</MudGrid>
</MudExpansionPanel>
}
</MudExpansionPanels>
</div>
</MudPaper>
</MudItem>
</MudGrid>
</MudItem>
<!-- ================= CỘT PHẢI (50%) - JSON Output ================= -->
<MudItem xs="12" md="5" Class="h-100">
<MudPaper Class="pa-4 h-100 d-flex flex-column" Elevation="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4 flex-shrink-0">
<MudText Typo="Typo.h6">📄 JSON Output (/order)</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Success"
StartIcon="@Icons.Material.Filled.Send"
OnClick="SendOrderToServer">
Send
</MudButton>
@if (!string.IsNullOrEmpty(sendResult))
{
<MudAlert Severity="@(sendResult.StartsWith("✅") ? Severity.Success : Severity.Error)"
Class="mt-3">
@sendResult
</MudAlert>
}
<MudTooltip Text="@(copied ? "Copied!" : "Copy to clipboard")">
<MudButton Variant="Variant.Filled"
Color="@(copied ? Color.Success : Color.Primary)"
Size="Size.Small"
StartIcon="@(copied ? Icons.Material.Filled.Check : Icons.Material.Filled.ContentCopy)"
OnClick="CopyJsonToClipboard">
@if (copied)
{
<MudText Class="ml-2">Copied!</MudText>
}
else
{
<MudText Class="ml-2">Copy</MudText>
}
</MudButton>
</MudTooltip>
</MudStack>
<!-- Cuộn nội bộ cho JSON -->
<div class="flex-grow-1" style="overflow:auto;">
<MudTextField Value="@OrderJson"
ReadOnly="true"
Variant="Variant.Filled"
Lines="70"
Class="h-100"
Style="font-family: 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.875rem;
background-color: #1e1e1e;
color: #d4d4d4;" />
</div>
</MudPaper>
</MudItem>
</MudGrid>
</MudContainer>
</div>
</MudMainContent>
<script>
window.copyText = async function (text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
return true;
} catch (err) {
console.error("Copy failed", err);
return false;
} finally {
document.body.removeChild(textarea);
}
};
</script>
@code {
[Inject] HttpClient Http { get; set; } = default!;
bool sending;
string? sendResult;
bool IsEditNodeOpen;
Node? EditingNode;
OrderMessage Order = new();
async Task SendOrderToServer()
{
if (sending)
return;
sending = true;
sendResult = null;
StateHasChanged();
try
{
var response = await Http.PostAsJsonAsync(
"/api/order",
JsonSerializer.Deserialize<JsonElement>(OrderJson)
);
if (response.IsSuccessStatusCode)
{
sendResult = "✅ Done!";
}
else
{
sendResult = $"❌ Failed: {response.StatusCode}";
}
}
catch (Exception ex)
{
sendResult = $"❌ Error: {ex.Message}";
}
finally
{
sending = false;
StateHasChanged();
}
}
void AddNode()
{
Order.Nodes.Add(new Node
{
NodeId = $"NODE_{Order.Nodes.Count + 1}",
SequenceId = Order.Nodes.Count,
Released = true,
NodePosition = new NodePosition()
});
}
void RemoveNode(Node node)
{
Order.Nodes.Remove(node);
Order.Edges.RemoveAll(e =>
e.StartNodeId == node.NodeId ||
e.EndNodeId == node.NodeId);
for (int i = 0; i < Order.Nodes.Count; i++)
Order.Nodes[i].SequenceId = i;
}
void RemoveAction(Node node, VDA5050.InstantAction.Action act)
{
node.Actions = node.Actions
.Where(a => a != act)
.ToArray();
}
void AddAction(Node node)
{
node.Actions = node.Actions
.Append(new VDA5050.InstantAction.Action
{
ActionId = Guid.NewGuid().ToString(),
ActionType = ActionType.startPause.ToString(),
BlockingType = "NONE",
ActionParameters = Array.Empty<ActionParameter>()
})
.ToArray();
}
private void RemoveActionParameter(VDA5050.InstantAction.Action act, ActionParameter param)
{
if (act.ActionParameters == null) return;
act.ActionParameters = act.ActionParameters
.Where(p => p != param)
.ToArray();
}
void AddActionParameter(VDA5050.InstantAction.Action act)
{
var newList = (act.ActionParameters ?? Array.Empty<ActionParameter>()).ToList();
newList.Add(new UiActionParameter());
act.ActionParameters = newList.ToArray();
}
void AddEdge()
{
if (Order.Nodes.Count < 2) return;
Order.Edges.Add(new VDA5050.Order.Edge
{
EdgeId = $"EDGE_{Order.Edges.Count + 1}",
StartNodeId = Order.Nodes[^2].NodeId,
EndNodeId = Order.Nodes[^1].NodeId
});
}
bool copied = false;
CancellationTokenSource? _copyCts;
async Task CopyJsonToClipboard()
{
_copyCts?.Cancel();
_copyCts = new();
var success = await JS.InvokeAsync<bool>(
"copyText",
OrderJson
);
if (!success)
return;
copied = true;
StateHasChanged();
try
{
await Task.Delay(1500, _copyCts.Token);
}
catch { }
copied = false;
StateHasChanged();
}
void RemoveEdge(VDA5050.Order.Edge edge)
{
Order.Edges.Remove(edge);
}
string OrderJson =>
JsonSerializer.Serialize(
Order.ToSchemaObject(),
new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
public class UiActionParameter : ActionParameter
{
[JsonIgnore]
public string ValueString
{
get => Value?.ToString() ?? "";
set => Value = value;
}
}
public class OrderMessage
{
public int HeaderId { get; set; }
public string Timestamp { get; set; } = "";
public string Version { get; set; } = "v1";
public string Manufacturer { get; set; } = "PNKX";
public string SerialNumber { get; set; } = "AMR-01";
public string OrderId { get; set; } = Guid.NewGuid().ToString();
public int OrderUpdateId { get; set; }
public string? ZoneSetId { get; set; }
public List<Node> Nodes { get; set; } = new();
public List<VDA5050.Order.Edge> Edges { get; set; } = new();
public object ToSchemaObject()
{
int seq = 0;
return new
{
headerId = HeaderId++,
timestamp = string.IsNullOrWhiteSpace(Timestamp)
? DateTime.UtcNow.ToString("O")
: Timestamp,
version = Version,
manufacturer = Manufacturer,
serialNumber = SerialNumber,
orderId = OrderId,
orderUpdateId = OrderUpdateId,
zoneSetId = string.IsNullOrWhiteSpace(ZoneSetId)
? null
: ZoneSetId,
// ================= NODES =================
nodes = Nodes
.Select(n => new
{
nodeId = n.NodeId,
sequenceId = seq++,
released = n.Released,
nodePosition = new
{
x = n.NodePosition.X,
y = n.NodePosition.Y,
theta = n.NodePosition.Theta,
allowedDeviationXY = n.NodePosition.AllowedDeviationXY,
allowedDeviationTheta = n.NodePosition.AllowedDeviationTheta,
mapId = string.IsNullOrWhiteSpace(n.NodePosition.MapId)
? "MAP_01"
: n.NodePosition.MapId
},
actions = n.Actions
.Select(a => new
{
actionId = a.ActionId,
actionType = a.ActionType,
blockingType = a.BlockingType,
actionParameters = a.ActionParameters != null
? a.ActionParameters
.Select(p => new
{
key = p.Key,
value = p.Value
})
.ToArray()
: Array.Empty<object>()
})
.ToArray()
})
.ToArray(), // ✅ QUAN TRỌNG
// ================= EDGES =================
edges = Edges
.Select(e => new
{
edgeId = e.EdgeId,
sequenceId = seq++,
released = true,
startNodeId = e.StartNodeId,
endNodeId = e.EndNodeId,
actions = Array.Empty<object>()
})
.ToArray() // ✅ QUAN TRỌNG
};
}
}
private async Task OpenEditNodeDialog(Node node)
{
var parameters = new DialogParameters<EditNodeDialog>
{
{ x => x.Node, node } // Truyền trực tiếp reference gốc
};
var options = new DialogOptions
{
CloseButton = true,
MaxWidth = MaxWidth.Large,
FullWidth = true,
CloseOnEscapeKey = true
};
var dialog = await DialogService.ShowAsync<EditNodeDialog>($"Edit Node: {node.NodeId}", parameters, options);
await dialog.Result; // Đợi dialog đóng
StateHasChanged();
}
}