Files
Mikrotik-Installer/src/MikrotikInstaller.App/ViewModels/Steps/ExpertStepViewModel.cs
T
adminandClaude Opus 5.5 f2dd7e790e
Build / build (push) Canceled after 0s
UI-Rework: Bereichs-Navigation statt Interview (Loesung A, #72)
- Seitenleiste mit Verbindungskarte und frei anklickbaren Bereichen
  Start, Einrichten, Uebersicht, Geraete, Backup & Wartung, Experte;
  Hilfe & Begriffe und Einstellungen als Bereiche unten (vorher Fenster)
- Ohne Verbindung: Verbinden-Seite, Geraetebereiche gesperrt
- Einrichten: Assistent mit anklickbaren Schritten und eigener
  Zurueck/Weiter-Leiste (SetupWizardViewModel)
- Start: Schnellzugriffe, Geraetedaten, RouterOS-Update
- Bereichswahl-Schritt und SelectedArea entfallen; Plan wird bei jedem
  neuen Verbinden zurueckgesetzt (WizardSession.ResetPlan)
- Fix: Entfernen eines bekannten Routers in der Seitenleiste band an
  ein nicht vorhandenes Kommando

Closes #72

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:37:52 +02:00

296 lines
9.6 KiB
C#

using MikrotikInstaller.Core.Localization;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MikrotikInstaller.Core.Connectivity;
using MikrotikInstaller.Core.Expert;
namespace MikrotikInstaller.App.ViewModels.Steps;
/// <summary>Ein Eintrag in der Menüliste links: ein kuratiertes Menü oder ein Werkzeug.</summary>
public sealed record ExpertMenuItem(string Title, string Group, string? Path, MenuSchema? Schema, string? Tool = null);
/// <summary>Ein Listeneintrag eines Menüs.</summary>
public sealed class ExpertEntryViewModel(IReadOnlyDictionary<string, string> row, MenuSchema? schema)
{
public IReadOnlyDictionary<string, string> Row { get; } = row;
public string? Id => Row.GetValueOrDefault(".id");
/// <summary>Die wichtigsten Spalten des Menüs, sonst die ersten sinnvollen Felder.</summary>
public string Summary
{
get
{
var keys = schema?.Columns is { Count: > 0 } columns
? columns
: Row.Keys.Where(k => !k.StartsWith('.') && k is not ("disabled" or "dynamic" or "invalid")).Take(4).ToList();
var parts = keys.Select(k => Row.GetValueOrDefault(k)).Where(v => !string.IsNullOrEmpty(v));
var text = string.Join(" · ", parts);
return text.Length > 0 ? text : Id ?? "(Eintrag)";
}
}
public bool IsDisabled => ExpertMenus.IsDisabled(Row);
public bool IsDynamic => ExpertMenus.IsTrue(Row, "dynamic");
public bool CanDelete => ExpertMenus.CanDelete(schema, Row);
}
/// <summary>Ein Feld im Bearbeiten-Bereich, mit Hilfe aus dem Katalog.</summary>
public partial class ExpertFieldViewModel : ObservableObject
{
public ExpertFieldViewModel(string key, string label, string help, string kind, IReadOnlyList<string> options, string original)
{
Key = key;
Label = label;
Help = help;
Kind = kind;
Options = options;
Original = kind == "bool" ? NormalizeBool(original) : original;
_value = Original;
}
public string Key { get; }
public string Label { get; }
public string Help { get; }
public string Kind { get; }
public IReadOnlyList<string> Options { get; }
public string Original { get; }
public bool IsBool => Kind == "bool";
public bool HasOptions => !IsBool && Options.Count > 0;
public bool IsText => !IsBool && Options.Count == 0;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsChecked))]
private string _value;
public bool IsChecked
{
get => Value == "true";
set => Value = value ? "true" : "false";
}
/// <summary>RouterOS liefert "true"/"false", akzeptiert aber auch "yes"/"no" — einheitlich vergleichen.</summary>
private static string NormalizeBool(string value) => value switch
{
"yes" or "true" => "true",
"no" or "false" => "false",
_ => value,
};
}
/// <summary>
/// Experte-Bereich (Issue #21, Mac „Experte“-Tab): kuratierte RouterOS-Menüs mit Feldhilfen und ein
/// eigener Menüpfad für alles andere. Auflisten, bearbeiten, anlegen, löschen — jede Änderung zeigt vorher
/// den genauen Befehl, vor der ersten Änderung entsteht eine Sicherung. Nur mit „Professioneller Netzwerker“.
/// </summary>
public partial class ExpertStepViewModel : WizardStepViewModelBase
{
private readonly WizardSession _session;
public ExpertStepViewModel(WizardSession session)
: base(
title: L.T("Experte"),
description: L.T("Direkter Zugriff auf RouterOS-Menüs. Jede Änderung zeigt vorher den genauen Befehl; vor der ersten Änderung wird eine Sicherung erstellt."))
{
_session = session;
Menus = [.. ExpertTools.Items, .. MenuCatalog.All.Select(m => new ExpertMenuItem(m.Title, m.Group, m.Path, m))];
Tools = new ExpertTools(session);
}
public IReadOnlyList<ExpertMenuItem> Menus { get; }
/// <summary>Werkzeuge (Netzwerke isolieren, Neues LAN, Port in Bridge, Pakete).</summary>
public ExpertTools Tools { get; }
public ObservableCollection<ExpertEntryViewModel> Entries { get; } = [];
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsMenuSelected))]
[NotifyPropertyChangedFor(nameof(IsToolSelected))]
[NotifyPropertyChangedFor(nameof(CanAdd))]
private ExpertMenuItem? _selectedMenu;
public bool IsMenuSelected => SelectedMenu is { Tool: null };
public bool IsToolSelected => SelectedMenu is { Tool: not null };
public bool CanAdd => SelectedMenu?.Schema is not { Singleton: true } and not { FixedList: true } && IsMenuSelected;
[ObservableProperty]
private string _customPath = string.Empty;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string? _errorMessage;
[ObservableProperty]
private string? _statusMessage;
/// <summary>Der geöffnete Bearbeiten-Bereich, null wenn geschlossen (gemeinsam mit der Übersicht, Issue #24).</summary>
[ObservableProperty]
private ExpertEditorViewModel? _editor;
public override async Task OnActivatedAsync()
{
await Tools.ActivateAsync();
if (SelectedMenu is { Path: not null })
{
await LoadEntriesAsync();
}
}
partial void OnSelectedMenuChanged(ExpertMenuItem? value)
{
Tools.ActiveTool = value?.Tool;
Editor = null;
Entries.Clear();
ErrorMessage = null;
StatusMessage = null;
if (value is { Path: not null })
{
_ = LoadEntriesAsync();
}
}
[RelayCommand]
private void OpenCustomPath()
{
if (string.IsNullOrWhiteSpace(CustomPath))
{
return;
}
var path = MenuCatalog.NormalizePath(CustomPath);
SelectedMenu = Menus.FirstOrDefault(m => m.Path == path)
?? new ExpertMenuItem(path, L.T("Eigener Menüpfad"), path, MenuCatalog.Find(path));
}
[RelayCommand]
private Task Refresh() => LoadEntriesAsync();
private async Task LoadEntriesAsync()
{
if (_session.Client is not { } client || SelectedMenu?.Path is not { } path)
{
return;
}
IsLoading = true;
ErrorMessage = null;
Entries.Clear();
try
{
foreach (var row in await client.GetAsync(path))
{
Entries.Add(new ExpertEntryViewModel(row, SelectedMenu.Schema));
}
if (SelectedMenu.Schema is { Singleton: true } && Entries.Count > 0)
{
EditEntry(Entries[0]);
}
}
catch (RouterOsException ex)
{
ErrorMessage = DescribeLoadError(path, ex);
}
finally
{
IsLoading = false;
}
}
/// <summary>Fehlt ein Bereich, weil er ein eigenes Paket ist (z. B. Hotspot auf dem hAP lite), verständlich erklären (Mac v1.11.0).</summary>
private static string DescribeLoadError(string path, RouterOsException ex) =>
path.StartsWith("/ip/hotspot", StringComparison.OrdinalIgnoreCase)
? L.T("Hotspot ist auf diesem Router ein eigenes Zusatzpaket und noch nicht installiert. Unter „Werkzeuge → Pakete“ lässt sich das Paket „hotspot“ installieren.")
: ex.Message == L.T("Diese Einstellung wird von deinem Gerät nicht unterstützt.")
? L.F("Den Menüpfad „{0}“ gibt es auf diesem Router nicht (RouterOS-Version oder fehlendes Paket).", path)
: ex.Message;
[RelayCommand]
private void EditEntry(ExpertEntryViewModel entry) => OpenEditor(entry.Row);
[RelayCommand]
private void NewEntry() => OpenEditor(null);
private void OpenEditor(IReadOnlyDictionary<string, string>? row)
{
if (SelectedMenu?.Path is not { } path)
{
return;
}
ExpertEditorViewModel? editor = null;
editor = new ExpertEditorViewModel(_session, path, SelectedMenu.Schema, row, Tools.InterfaceNames, async () =>
{
StatusMessage = L.F("Gespeichert: {0}", editor!.LastSavedCommand);
await LoadEntriesAsync();
});
editor.Closed += (_, _) =>
{
if (Editor == editor)
{
Editor = null;
}
};
Editor = editor;
}
[RelayCommand]
private async Task DeleteEntryAsync(ExpertEntryViewModel entry)
{
if (_session.Client is not { } client || SelectedMenu?.Path is not { } path || entry.Id is not { } id || !entry.CanDelete)
{
return;
}
var preview = ExpertMenus.Preview(path, "remove", id, new Dictionary<string, string>());
if (System.Windows.MessageBox.Show(L.F("Diesen Eintrag löschen?\n\n{0}\n\nBefehl: {1}", entry.Summary, preview), L.T("Eintrag löschen"),
System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Warning) != System.Windows.MessageBoxResult.Yes)
{
return;
}
IsLoading = true;
ErrorMessage = null;
try
{
if (!_session.ExpertBackupDone)
{
StatusMessage = L.T("Sicherung vor der ersten Änderung wird erstellt …");
await _session.CreateSafetyBackupAsync(client);
_session.ExpertBackupDone = true;
}
await client.RemoveAsync(path, id);
StatusMessage = L.F("Gelöscht: {0}", preview);
}
catch (RouterOsException ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsLoading = false;
}
await LoadEntriesAsync();
}
}