L1a: Core und Module von WinForms entkoppeln - net10.0 statt net10.0-windows
Core, alle drei Module und das Testprojekt tragen keinen UI-Code mehr und bauen fuer linux-x64. Nur noch IBKRTrader.App ist Windows-gebunden. UI-Contract toolkit-neutral (Vorbild: PolytraderSharp): - ModuleView.CreateForm (Func<Form>) -> CreateView (Func<object>) - ModuleView.Icon (System.Drawing.Image) -> IconKey (string). System.Drawing.Common ist seit .NET 7 Windows-only und wirft auf Linux. - WindowMenu.cs war reine WinForms-Umsetzung -> in die Shell verschoben. LoggingService haelt keine RichTextBox mehr, sondern meldet Eintraege ueber event EntryWritten. Einfaerbung und UI-Thread-Wechsel liegen jetzt im LogPanelController der Shell. Nebenbei: ToUpper() -> ToUpperInvariant() (tr-TR haette aus "info" ein "İNFO" gemacht) und \r\n -> Environment.NewLine. Die drei Modul-Fenster liegen jetzt unter UI/Views/Modules/; RegisterUi der Module ist bewusst leer, die Shell registriert sie zentral ueber UI/ModuleViews.cs (nur fuer tatsaechlich geladene Module). ViewIcons loest IconKey gegen die PNG-Ressourcen auf - dieselben Schluessel bekommt spaeter die Avalonia-Shell. UiConstructionTests entfernt: die Konstruktionspruefung deckt --smoke-ui ab, das Testprojekt braucht dafuer keine UI-Referenz mehr. Der Test RegisterUi_RegistersMainView haelt jetzt das Gegenteil fest - das Modul darf nichts registrieren, sonst waere es wieder toolkit-gebunden. Verifiziert: Build 0 Fehler/0 Warnungen, 163 Tests gruen, --smoke-ui konstruiert alle 7 Fenster, und Core + 3 Module + Tests bauen fuer linux-x64. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,19 @@ using IBKRTrader.Core.Logging;
|
||||
namespace IBKRTrader.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Steuert das Log-Panel (RichTextBox im Logs-Tab).
|
||||
/// Bietet Clear- und Filter-Funktionalität.
|
||||
/// Bindet eine <see cref="RichTextBox"/> an den <see cref="LoggingService"/> und färbt die Zeilen
|
||||
/// nach Log-Level ein.
|
||||
///
|
||||
/// <para>Einfärbung und Wechsel auf den UI-Thread liegen bewusst hier und nicht mehr im
|
||||
/// <see cref="LoggingService"/>: der Core trägt seit der Linux-Portierung keine UI-Abhängigkeit
|
||||
/// (weder WinForms noch <c>System.Drawing</c>). Der Dienst meldet nur noch Einträge.</para>
|
||||
/// </summary>
|
||||
public class LogPanelController
|
||||
public sealed class LogPanelController : IDisposable
|
||||
{
|
||||
private static readonly Color ColorInfo = Color.FromArgb(150, 210, 150);
|
||||
private static readonly Color ColorWarn = Color.FromArgb(255, 190, 60);
|
||||
private static readonly Color ColorError = Color.FromArgb(255, 80, 80);
|
||||
|
||||
private readonly RichTextBox _rtb;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
@@ -16,15 +24,53 @@ public class LogPanelController
|
||||
_rtb = rtb;
|
||||
_logger = logger;
|
||||
|
||||
// Logging-Service mit RichTextBox verbinden
|
||||
_logger.AttachRichTextBox(rtb);
|
||||
|
||||
// Hintergrund der RTB auf dunkles Theme setzen
|
||||
// Dunkles Theme fürs Log-Panel.
|
||||
_rtb.BackColor = Color.FromArgb(20, 20, 30);
|
||||
_rtb.ForeColor = Color.FromArgb(200, 200, 200);
|
||||
_rtb.Font = new Font("Consolas", 9f);
|
||||
_rtb.ReadOnly = true;
|
||||
_rtb.WordWrap = false;
|
||||
|
||||
_logger.EntryWritten += OnEntryWritten;
|
||||
_rtb.Disposed += (_, _) => Dispose();
|
||||
}
|
||||
|
||||
public void Dispose() => _logger.EntryWritten -= OnEntryWritten;
|
||||
|
||||
/// <summary>
|
||||
/// Wird aus beliebigen Worker-Threads gerufen – deshalb der Wechsel auf den UI-Thread. Ein
|
||||
/// Fehler hier darf den schreibenden Worker niemals mitreißen.
|
||||
/// </summary>
|
||||
private void OnEntryWritten(LogEntry e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_rtb.IsDisposed) return;
|
||||
|
||||
var color = e.Level switch
|
||||
{
|
||||
AppLogLevel.Warn => ColorWarn,
|
||||
AppLogLevel.Error => ColorError,
|
||||
_ => ColorInfo
|
||||
};
|
||||
var text = LoggingService.Format(e) + Environment.NewLine;
|
||||
|
||||
if (_rtb.InvokeRequired) _rtb.BeginInvoke(() => AppendColored(text, color));
|
||||
else AppendColored(text, color);
|
||||
}
|
||||
catch { /* Fenster wird gerade geschlossen */ }
|
||||
}
|
||||
|
||||
private void AppendColored(string text, Color color)
|
||||
{
|
||||
if (_rtb.IsDisposed) return;
|
||||
_rtb.SelectionStart = _rtb.TextLength;
|
||||
_rtb.SelectionLength = 0;
|
||||
_rtb.SelectionColor = color;
|
||||
_rtb.AppendText(text);
|
||||
_rtb.SelectionColor = _rtb.ForeColor;
|
||||
if (_rtb.TextLength > 0)
|
||||
_rtb.ScrollToCaret();
|
||||
}
|
||||
|
||||
public void Clear() => _rtb.Clear();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using IBKRTrader.Core.Analytics;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.Modules.Accounting.Persistence;
|
||||
using IBKRTrader.Modules.Accounting.Services;
|
||||
using IBKRTrader.Modules.CongressTrading.Database;
|
||||
using IBKRTrader.Modules.Supervisor.Agent;
|
||||
using IBKRTrader.Modules.Supervisor.Persistence;
|
||||
using IBKRTrader.Modules.Supervisor.Services;
|
||||
using IBKRTrader.UI.Views.Modules;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Registriert die Fenster der Module bei der Shell.
|
||||
///
|
||||
/// <para><b>Warum hier und nicht im Modul?</b> Ein Modul, das sein eigenes Fenster erzeugt, muss das
|
||||
/// UI-Toolkit referenzieren – und wäre damit an WinForms bzw. Avalonia gebunden. Genau das verhindert
|
||||
/// den kopflosen Linux-Betrieb. Die Modulprojekte bleiben deshalb frei von UI-Code; ihr
|
||||
/// <c>RegisterUi</c> ist leer, und die Shell verdrahtet die Fenster zentral. Die Modul-Dienste
|
||||
/// kommen unverändert aus dem DI-Container.</para>
|
||||
///
|
||||
/// <para>Registriert wird nur, was auch geladen ist: fehlt ein Modul in dieser Sitzung, entfällt
|
||||
/// sein Fenster, und der Launcher zeigt es gar nicht erst an.</para>
|
||||
/// </summary>
|
||||
public static class ModuleViews
|
||||
{
|
||||
public static void Register(IModuleUiHost host, IServiceProvider sp)
|
||||
{
|
||||
RegisterIfLoaded(sp, "CongressTrading", () => host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "congresstrading.main", Title = "Congress Trading", Group = "CongressTrading", Order = 100,
|
||||
CreateView = () => new CongressTradingForm(
|
||||
sp.GetRequiredService<CongressRepository>(),
|
||||
sp.GetRequiredService<WorkerEngine>(),
|
||||
sp.GetRequiredService<IPortfolioService>(),
|
||||
sp.GetRequiredService<LoggingService>())
|
||||
}));
|
||||
|
||||
RegisterIfLoaded(sp, "Supervisor", () => host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "supervisor.main", Title = "Supervisor", Group = "Supervisor", Order = 300,
|
||||
CreateView = () => new SupervisorMainForm(
|
||||
sp.GetRequiredService<SupervisorAgent>(),
|
||||
sp.GetRequiredService<DossierService>(),
|
||||
sp.GetRequiredService<ISupervisorReportRepository>(),
|
||||
sp.GetRequiredService<LoggingService>())
|
||||
}));
|
||||
|
||||
RegisterIfLoaded(sp, "Accounting", () => host.RegisterView(new ModuleView
|
||||
{
|
||||
Id = "accounting.main", Title = "Accounting", Group = "Accounting", Order = 400,
|
||||
CreateView = () => new AccountingMainForm(
|
||||
sp.GetRequiredService<ILedgerRepository>(),
|
||||
sp.GetRequiredService<IIngestRunRepository>(),
|
||||
sp.GetRequiredService<AccountingReportService>(),
|
||||
sp.GetRequiredService<AccountingIngestService>(),
|
||||
sp.GetRequiredService<LoggingService>())
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist.</summary>
|
||||
private static void RegisterIfLoaded(IServiceProvider sp, string moduleName, Action register)
|
||||
{
|
||||
if (sp.GetServices<IModule>().Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase)))
|
||||
register();
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -69,7 +69,9 @@ public sealed class ShellUiHost : IModuleUiHost
|
||||
return;
|
||||
}
|
||||
|
||||
var form = view.CreateForm();
|
||||
// Der Core-Contract ist toolkit-neutral (CreateView liefert object). Ein anderer Typ als
|
||||
// Form ist ein Programmierfehler und soll laut scheitern, nicht still ein leeres Fenster geben.
|
||||
var form = (Form)view.CreateView();
|
||||
if (string.IsNullOrEmpty(form.Text) || form.Text == form.Name)
|
||||
form.Text = view.Title;
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Drawing;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
|
||||
namespace IBKRTrader.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Löst die toolkit-neutralen <see cref="ModuleView.IconKey"/>-Schlüssel gegen die Bildressourcen
|
||||
/// der Shell auf. Der Core kennt seit der Linux-Portierung keine Bilddaten mehr
|
||||
/// (<c>System.Drawing.Image</c> ist seit .NET 7 Windows-only) – er liefert nur noch den Schlüssel,
|
||||
/// die jeweilige Shell das Bild.
|
||||
///
|
||||
/// <para>Die Avalonia-Shell bekommt ein gleichnamiges Gegenstück mit <b>denselben Schlüsseln</b>
|
||||
/// und denselben PNG-Dateien, sodass Core und Module unverändert bleiben.</para>
|
||||
/// </summary>
|
||||
public static class ViewIcons
|
||||
{
|
||||
/// <summary>Symbol-Schlüssel → Bildressource aus <c>Resources/</c>.</summary>
|
||||
private static readonly Dictionary<string, Image> ImageByKey = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["dashboard"] = Properties.Resources.dashboard,
|
||||
["workers"] = Properties.Resources.system_time,
|
||||
["logs"] = Properties.Resources.error_log,
|
||||
["settings"] = Properties.Resources.setting_tools,
|
||||
["congresstrading"] = Properties.Resources.cross_reference,
|
||||
["accounting"] = Properties.Resources.coins_in_hand,
|
||||
["supervisor"] = Properties.Resources.token_quantifier,
|
||||
};
|
||||
|
||||
/// <summary>Standard-Symbolschlüssel je View-ID – greift für Views, die keinen eigenen setzen.</summary>
|
||||
private static readonly Dictionary<string, string> DefaultKeyByViewId = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["core.dashboard"] = "dashboard",
|
||||
["core.workers"] = "workers",
|
||||
["core.logs"] = "logs",
|
||||
["core.settings"] = "settings",
|
||||
["congresstrading.main"] = "congresstrading",
|
||||
["accounting.main"] = "accounting",
|
||||
["supervisor.main"] = "supervisor",
|
||||
};
|
||||
|
||||
/// <summary>Bild zum Schlüssel, oder <c>null</c> (kein Symbol / unbekannter Schlüssel).</summary>
|
||||
public static Image? Resolve(string? iconKey) =>
|
||||
iconKey != null && ImageByKey.TryGetValue(iconKey, out var img) ? img : null;
|
||||
|
||||
/// <summary>Setzt bei allen registrierten Views den Standard-Schlüssel, falls noch keiner gesetzt ist.</summary>
|
||||
public static void AssignDefaults(IModuleUiHost host)
|
||||
{
|
||||
foreach (var view in host.Views)
|
||||
if (view.IconKey is null && DefaultKeyByViewId.TryGetValue(view.Id, out var key))
|
||||
view.IconKey = key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Modules.Accounting.Logic;
|
||||
using IBKRTrader.Modules.Accounting.Persistence;
|
||||
using IBKRTrader.Modules.Accounting.Services;
|
||||
|
||||
namespace IBKRTrader.UI.Views.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Fenster des Accounting-Moduls: Übersicht/BWA, Ledger, Steuer (Platzhalter), Abrechnung/Export,
|
||||
/// Abruf/Status. Alle DB-Zugriffe laufen NUR auf Nutzer-Interaktion (nicht im Konstruktor) – so
|
||||
/// konstruiert der Smoke-UI-Check das Fenster auch ohne DB fehlerfrei.
|
||||
/// </summary>
|
||||
public sealed class AccountingMainForm : Form
|
||||
{
|
||||
private readonly ILedgerRepository _ledger;
|
||||
private readonly IIngestRunRepository _runs;
|
||||
private readonly AccountingReportService _report;
|
||||
private readonly AccountingIngestService _ingest;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
private readonly DateTimePicker _from = new() { Format = DateTimePickerFormat.Short, Width = 110 };
|
||||
private readonly DateTimePicker _to = new() { Format = DateTimePickerFormat.Short, Width = 110 };
|
||||
private readonly ComboBox _account = new() { DropDownStyle = ComboBoxStyle.DropDown, Width = 140 };
|
||||
private readonly ComboBox _currency = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 80 };
|
||||
|
||||
private readonly Label _kpis = new() { AutoSize = true, Location = new Point(12, 8) };
|
||||
private readonly DataGridView _monthly = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
|
||||
private readonly DataGridView _ledgerGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
|
||||
private readonly DataGridView _runsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
|
||||
private readonly Label _status = new() { AutoSize = true, ForeColor = SystemColors.GrayText, Location = new Point(12, 8) };
|
||||
|
||||
public AccountingMainForm(
|
||||
ILedgerRepository ledger, IIngestRunRepository runs, AccountingReportService report,
|
||||
AccountingIngestService ingest, LoggingService logger)
|
||||
{
|
||||
_ledger = ledger;
|
||||
_runs = runs;
|
||||
_report = report;
|
||||
_ingest = ingest;
|
||||
_logger = logger;
|
||||
|
||||
Text = "Accounting";
|
||||
Width = 1000;
|
||||
Height = 680;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimumSize = new Size(760, 480);
|
||||
|
||||
_from.Value = DateTime.Today.AddMonths(-1);
|
||||
_to.Value = DateTime.Today;
|
||||
_currency.Items.AddRange(new object[] { "USD", "EUR" });
|
||||
_currency.SelectedIndex = 0;
|
||||
|
||||
BuildLayout();
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var tabs = new TabControl { Dock = DockStyle.Fill };
|
||||
|
||||
// ── gemeinsame Filterleiste ──
|
||||
var filter = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) };
|
||||
filter.Controls.Add(new Label { Text = "Von", AutoSize = true, Margin = new Padding(0, 8, 4, 0) });
|
||||
filter.Controls.Add(_from);
|
||||
filter.Controls.Add(new Label { Text = "Bis", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
|
||||
filter.Controls.Add(_to);
|
||||
filter.Controls.Add(new Label { Text = "Konto", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
|
||||
filter.Controls.Add(_account);
|
||||
filter.Controls.Add(new Label { Text = "Währung", AutoSize = true, Margin = new Padding(8, 8, 4, 0) });
|
||||
filter.Controls.Add(_currency);
|
||||
var btnRefresh = new Button { Text = "Aktualisieren", Width = 120, Margin = new Padding(12, 3, 0, 0) };
|
||||
btnRefresh.Click += (_, _) => RefreshAll();
|
||||
filter.Controls.Add(btnRefresh);
|
||||
|
||||
// ── Tab: Übersicht/BWA ──
|
||||
var tabOverview = new TabPage("Übersicht / BWA");
|
||||
_monthly.Top = 90;
|
||||
var overviewPanel = new Panel { Dock = DockStyle.Fill };
|
||||
overviewPanel.Controls.Add(_monthly);
|
||||
var kpiPanel = new Panel { Dock = DockStyle.Top, Height = 84 };
|
||||
kpiPanel.Controls.Add(_kpis);
|
||||
overviewPanel.Controls.Add(kpiPanel);
|
||||
tabOverview.Controls.Add(overviewPanel);
|
||||
|
||||
// ── Tab: Ledger ──
|
||||
var tabLedger = new TabPage("Ledger");
|
||||
tabLedger.Controls.Add(_ledgerGrid);
|
||||
|
||||
// ── Tab: Steuer (Platzhalter) ──
|
||||
var tabTax = new TabPage("Steuer");
|
||||
tabTax.Controls.Add(new Label
|
||||
{
|
||||
Dock = DockStyle.Fill, Padding = new Padding(16),
|
||||
Text = "Steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt).\n\n" +
|
||||
"Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig.\n" +
|
||||
"Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D)\n" +
|
||||
"wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt.\n\n" +
|
||||
"Hinweis: Dies ist keine Steuerberatung."
|
||||
});
|
||||
|
||||
// ── Tab: Abrechnung / Export ──
|
||||
var tabExport = new TabPage("Abrechnung / Export");
|
||||
var exportPanel = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), FlowDirection = FlowDirection.TopDown };
|
||||
exportPanel.Controls.Add(new Label { AutoSize = true, Text = "Exportiert die aktuelle Auswahl (Zeitraum / Konto / Währung):" });
|
||||
var btnCsvLedger = new Button { Text = "Ledger als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
|
||||
btnCsvLedger.Click += (_, _) => ExportCsvLedger();
|
||||
var btnCsvStmt = new Button { Text = "Abrechnung als CSV…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
|
||||
btnCsvStmt.Click += (_, _) => ExportCsvStatement();
|
||||
var btnPdf = new Button { Text = "Abrechnung als PDF…", Width = 180, Margin = new Padding(0, 8, 0, 0) };
|
||||
btnPdf.Click += (_, _) => ExportPdf();
|
||||
exportPanel.Controls.Add(btnCsvLedger);
|
||||
exportPanel.Controls.Add(btnCsvStmt);
|
||||
exportPanel.Controls.Add(btnPdf);
|
||||
tabExport.Controls.Add(exportPanel);
|
||||
|
||||
// ── Tab: Abruf / Status ──
|
||||
var tabIngest = new TabPage("Abruf / Status");
|
||||
var ingestButtons = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 40, Padding = new Padding(8, 6, 8, 0) };
|
||||
var btnIncr = new Button { Text = "Inkrementell abrufen", Width = 160 };
|
||||
btnIncr.Click += async (_, _) => await RunIngest(backfill: false);
|
||||
var btnBackfill = new Button { Text = "Backfill (voll)", Width = 140, Margin = new Padding(8, 0, 0, 0) };
|
||||
btnBackfill.Click += async (_, _) => await RunIngest(backfill: true);
|
||||
ingestButtons.Controls.Add(btnIncr);
|
||||
ingestButtons.Controls.Add(btnBackfill);
|
||||
var statusPanel = new Panel { Dock = DockStyle.Top, Height = 40 };
|
||||
statusPanel.Controls.Add(_status);
|
||||
_status.Text = "Offline-Standard: keine Live-Quelle registriert → der Ingest bucht nichts (korrekt).";
|
||||
tabIngest.Controls.Add(_runsGrid);
|
||||
tabIngest.Controls.Add(statusPanel);
|
||||
tabIngest.Controls.Add(ingestButtons);
|
||||
|
||||
tabs.TabPages.AddRange(new[] { tabOverview, tabLedger, tabTax, tabExport, tabIngest });
|
||||
|
||||
Controls.Add(tabs);
|
||||
Controls.Add(filter);
|
||||
}
|
||||
|
||||
// ── Daten laden (nur auf Interaktion) ──
|
||||
|
||||
private string? SelectedAccount()
|
||||
{
|
||||
var text = _account.Text?.Trim();
|
||||
return string.IsNullOrWhiteSpace(text) || text == "(alle)" ? null : text;
|
||||
}
|
||||
|
||||
private void RefreshAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
LoadAccounts();
|
||||
LoadOverview();
|
||||
LoadLedger();
|
||||
LoadRuns();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Accounting", $"UI-Refresh fehlgeschlagen: {ex.Message}", ex);
|
||||
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadAccounts()
|
||||
{
|
||||
var current = _account.Text;
|
||||
_account.Items.Clear();
|
||||
_account.Items.Add("(alle)");
|
||||
foreach (var a in _ledger.DistinctAccounts()) _account.Items.Add(a);
|
||||
_account.Text = string.IsNullOrEmpty(current) ? "(alle)" : current;
|
||||
}
|
||||
|
||||
private void LoadOverview()
|
||||
{
|
||||
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
|
||||
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
|
||||
var view = _report.GetCurrencyView(_currency.Text, to);
|
||||
decimal C(decimal v) => Math.Round(v * view.Factor, 2);
|
||||
|
||||
_kpis.Text =
|
||||
$"Netto-Handelsergebnis: {C(stmt.NetTradingResult):N2} {view.Code} " +
|
||||
$"Handelsvolumen: {C(stmt.TradeVolume):N2} Dividenden: {C(stmt.Dividends):N2} Fees: {C(stmt.Fees):N2}\n" +
|
||||
$"Einzahlungen: {C(stmt.Deposits):N2} Auszahlungen: {C(stmt.Withdrawals):N2} " +
|
||||
$"Endsaldo: {C(stmt.ClosingBalance):N2} Trades: {stmt.TradeCount} Buchungen: {stmt.EntryCount}\n" +
|
||||
$"{view.Note}";
|
||||
|
||||
var monthly = _report.BuildMonthly(SelectedAccount(), from, to)
|
||||
.Select(m => new
|
||||
{
|
||||
Monat = m.From.ToString("yyyy-MM"),
|
||||
Anfang = C(m.OpeningBalance),
|
||||
Einzahlungen = C(m.Deposits),
|
||||
Auszahlungen = C(m.Withdrawals),
|
||||
Volumen = C(m.TradeVolume),
|
||||
Fees = C(m.Fees),
|
||||
Ergebnis = C(m.NetTradingResult),
|
||||
Endsaldo = C(m.ClosingBalance)
|
||||
}).ToList();
|
||||
_monthly.DataSource = monthly;
|
||||
}
|
||||
|
||||
private void LoadLedger()
|
||||
{
|
||||
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
|
||||
var rows = _ledger.Query(SelectedAccount(), from, to, 2000)
|
||||
.Select(e => new
|
||||
{
|
||||
Zeit = e.Timestamp, e.AccountId, Typ = e.EventType.ToString(), e.Side, e.Symbol,
|
||||
e.Currency, e.Quantity, Preis = e.PriceNative, Brutto = e.GrossBase, Fee = e.FeeBase,
|
||||
Netto = e.NetBase, e.TransactionId
|
||||
}).ToList();
|
||||
_ledgerGrid.DataSource = rows;
|
||||
}
|
||||
|
||||
private void LoadRuns()
|
||||
{
|
||||
var rows = _runs.GetRecent(SelectedAccount(), 100)
|
||||
.Select(r => new
|
||||
{
|
||||
r.AccountId, Start = r.StartedAt, Ende = r.FinishedAt, r.Backfill,
|
||||
Neu = r.NewEntries, Duplikate = r.DuplicateEntries, r.Success,
|
||||
Anker = r.BalanceAnchorBase, LedgerNetto = r.LedgerNetBase, Delta = r.BalanceDeltaBase, r.Message
|
||||
}).ToList();
|
||||
_runsGrid.DataSource = rows;
|
||||
}
|
||||
|
||||
// ── Export ──
|
||||
|
||||
private void ExportCsvLedger()
|
||||
{
|
||||
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
|
||||
var entries = _ledger.Query(SelectedAccount(), from, to, 100000);
|
||||
SaveText("ledger.csv", "CSV|*.csv", CsvExporter.Ledger(entries));
|
||||
}
|
||||
|
||||
private void ExportCsvStatement()
|
||||
{
|
||||
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
|
||||
var stmt = _report.BuildStatement(SelectedAccount(), from, to);
|
||||
SaveText("abrechnung.csv", "CSV|*.csv", CsvExporter.Statement(stmt));
|
||||
}
|
||||
|
||||
private void ExportPdf()
|
||||
{
|
||||
try
|
||||
{
|
||||
DateTime from = _from.Value.Date, to = _to.Value.Date.AddDays(1).AddTicks(-1);
|
||||
var account = SelectedAccount();
|
||||
var stmt = _report.BuildStatement(account, from, to);
|
||||
var monthly = _report.BuildMonthly(account, from, to);
|
||||
var entries = _ledger.Query(account, from, to, 100000).OrderBy(e => e.Timestamp).ToList();
|
||||
var view = _report.GetCurrencyView(_currency.Text, to);
|
||||
|
||||
byte[] pdf = PdfExporter.Render(stmt, monthly, entries, view.Code, view.Factor, view.Note);
|
||||
|
||||
using var dlg = new SaveFileDialog { FileName = "abrechnung.pdf", Filter = "PDF|*.pdf" };
|
||||
if (dlg.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
File.WriteAllBytes(dlg.FileName, pdf);
|
||||
_logger.Info("Accounting", $"PDF-Abrechnung geschrieben: {dlg.FileName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Accounting", $"PDF-Export fehlgeschlagen: {ex.Message}", ex);
|
||||
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveText(string suggested, string filter, string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var dlg = new SaveFileDialog { FileName = suggested, Filter = filter };
|
||||
if (dlg.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
File.WriteAllText(dlg.FileName, content);
|
||||
_logger.Info("Accounting", $"Export geschrieben: {dlg.FileName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Accounting", $"Export fehlgeschlagen: {ex.Message}", ex);
|
||||
MessageBox.Show(this, ex.Message, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ingest ──
|
||||
|
||||
private async Task RunIngest(bool backfill)
|
||||
{
|
||||
try
|
||||
{
|
||||
_status.Text = backfill ? "Backfill läuft…" : "Inkrementeller Abruf läuft…";
|
||||
await _ingest.IngestAllAsync(backfill, CancellationToken.None);
|
||||
_status.Text = $"Abruf abgeschlossen ({DateTime.Now:HH:mm:ss}).";
|
||||
LoadRuns();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_status.Text = $"Fehler: {ex.Message}";
|
||||
_logger.Error("Accounting", $"Manueller Ingest fehlgeschlagen: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.Modules.CongressTrading;
|
||||
using IBKRTrader.Modules.CongressTrading.Database;
|
||||
|
||||
namespace IBKRTrader.UI.Views.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Eigenständiges Fenster des CongressTrading-Moduls: DB-Kennzahlen, manueller Scrape-Trigger
|
||||
/// und die offenen Positionen des Moduls (aus dem Core-Portfolio).
|
||||
/// </summary>
|
||||
public sealed class CongressTradingForm : Form
|
||||
{
|
||||
private const string ScrapeWorkerName = "CT-ScrapeWorker";
|
||||
|
||||
private readonly CongressRepository _repo;
|
||||
private readonly WorkerEngine _engine;
|
||||
private readonly IPortfolioService _portfolio;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
private readonly Label _lblTrades = new() { AutoSize = true, Location = new Point(20, 70) };
|
||||
private readonly Label _lblMembers = new() { AutoSize = true, Location = new Point(20, 100) };
|
||||
private readonly Button _btnRefresh = new() { Text = "Aktualisieren", Location = new Point(20, 140), Width = 140 };
|
||||
private readonly Button _btnScrape = new() { Text = "Scrape jetzt", Location = new Point(170, 140), Width = 140 };
|
||||
private readonly Label _lblStatus = new() { AutoSize = true, Location = new Point(20, 185), ForeColor = SystemColors.GrayText };
|
||||
|
||||
private readonly DataGridView _positions = new()
|
||||
{
|
||||
Location = new Point(20, 250),
|
||||
Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right,
|
||||
ReadOnly = true,
|
||||
AllowUserToAddRows = false,
|
||||
RowHeadersVisible = false,
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill
|
||||
};
|
||||
|
||||
public CongressTradingForm(CongressRepository repo, WorkerEngine engine, IPortfolioService portfolio, LoggingService logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_engine = engine;
|
||||
_portfolio = portfolio;
|
||||
_logger = logger;
|
||||
|
||||
Text = "Congress Trading [CT]";
|
||||
Width = 900;
|
||||
Height = 600;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimumSize = new Size(600, 400);
|
||||
BuildLayout();
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var title = new Label
|
||||
{
|
||||
Text = "Congress Trading",
|
||||
Font = new Font(Font.FontFamily, 14f, FontStyle.Bold),
|
||||
Location = new Point(18, 20),
|
||||
AutoSize = true
|
||||
};
|
||||
|
||||
var posLabel = new Label
|
||||
{
|
||||
Text = "Offene Positionen (Modul CT):",
|
||||
Location = new Point(20, 225),
|
||||
AutoSize = true
|
||||
};
|
||||
|
||||
_positions.Size = new Size(ClientSize.Width - 40, ClientSize.Height - 270);
|
||||
|
||||
_btnRefresh.Click += async (_, _) => await RefreshStatsAsync();
|
||||
_btnScrape.Click += async (_, _) => await TriggerScrapeAsync();
|
||||
|
||||
Controls.Add(title);
|
||||
Controls.Add(_lblTrades);
|
||||
Controls.Add(_lblMembers);
|
||||
Controls.Add(_btnRefresh);
|
||||
Controls.Add(_btnScrape);
|
||||
Controls.Add(_lblStatus);
|
||||
Controls.Add(posLabel);
|
||||
Controls.Add(_positions);
|
||||
}
|
||||
|
||||
protected override async void OnShown(EventArgs e)
|
||||
{
|
||||
base.OnShown(e);
|
||||
await RefreshStatsAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshStatsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var trades = await _repo.GetTradeCountAsync();
|
||||
var members = await _repo.GetMemberCountAsync();
|
||||
_lblTrades.Text = $"Trades in DB: {trades:N0}";
|
||||
_lblMembers.Text = $"Mitglieder in DB: {members:N0}";
|
||||
|
||||
var positions = await _portfolio.GetPositionsAsync(CongressTradingModule.LogTag);
|
||||
_positions.DataSource = positions
|
||||
.Select(p => new { p.Symbol, Stück = p.Quantity, Ø_Kurs = p.AvgPrice, Wert = p.Notional })
|
||||
.ToList();
|
||||
|
||||
_lblStatus.Text = $"Aktualisiert: {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_lblTrades.Text = "Trades in DB: n/v";
|
||||
_lblMembers.Text = "Mitglieder in DB: n/v";
|
||||
_lblStatus.Text = $"DB nicht erreichbar: {ex.Message}";
|
||||
_logger.Warn(CongressTradingModule.LogTag, $"Kennzahlen konnten nicht geladen werden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TriggerScrapeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_lblStatus.Text = "Scrape angestoßen...";
|
||||
await _engine.TriggerWorkerAsync(ScrapeWorkerName);
|
||||
_logger.Info(CongressTradingModule.LogTag, "Scrape-Worker manuell ausgelöst (aus Modul-Fenster).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_lblStatus.Text = $"Scrape fehlgeschlagen: {ex.Message}";
|
||||
_logger.Error(CongressTradingModule.LogTag, "Manueller Scrape-Trigger fehlgeschlagen.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using IBKRTrader.Core.Analytics;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Modules.Supervisor.Agent;
|
||||
using IBKRTrader.Modules.Supervisor.Persistence;
|
||||
using IBKRTrader.Modules.Supervisor.Services;
|
||||
|
||||
namespace IBKRTrader.UI.Views.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Fenster des Supervisor-Moduls: Analyse (Chat mit dem Agenten, Tool-Aufrufe sichtbar), Dossier-Browser,
|
||||
/// Berichte, Settings. Read-only. DB-/Agent-Zugriffe laufen NUR auf Nutzer-Interaktion (Smoke-UI-sicher).
|
||||
/// </summary>
|
||||
public sealed class SupervisorMainForm : Form
|
||||
{
|
||||
private readonly SupervisorAgent _agent;
|
||||
private readonly DossierService _dossiers;
|
||||
private readonly ISupervisorReportRepository _reports;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
private readonly ComboBox _profile = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 160 };
|
||||
private readonly TextBox _question = new() { Dock = DockStyle.Fill, Multiline = true, Height = 60 };
|
||||
private readonly RichTextBox _answer = new() { Dock = DockStyle.Fill, ReadOnly = true, Font = new Font("Consolas", 9f) };
|
||||
private readonly Button _ask = new() { Text = "Fragen", Width = 100 };
|
||||
|
||||
private readonly DataGridView _signals = new() { Dock = DockStyle.Left, Width = 360, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
|
||||
private readonly RichTextBox _dossier = new() { Dock = DockStyle.Fill, ReadOnly = true, Font = new Font("Consolas", 9f) };
|
||||
private readonly DataGridView _reportsGrid = new() { Dock = DockStyle.Fill, ReadOnly = true, AllowUserToAddRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill };
|
||||
|
||||
public SupervisorMainForm(
|
||||
SupervisorAgent agent, DossierService dossiers, ISupervisorReportRepository reports, LoggingService logger)
|
||||
{
|
||||
_agent = agent;
|
||||
_dossiers = dossiers;
|
||||
_reports = reports;
|
||||
_logger = logger;
|
||||
|
||||
Text = "Supervisor";
|
||||
Width = 1080;
|
||||
Height = 720;
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
MinimumSize = new Size(800, 520);
|
||||
|
||||
foreach (var p in SupervisorProfiles.All) _profile.Items.Add(p.Name);
|
||||
_profile.SelectedIndex = 0;
|
||||
|
||||
BuildLayout();
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var tabs = new TabControl { Dock = DockStyle.Fill };
|
||||
|
||||
// ── Tab: Analyse ──
|
||||
var tabChat = new TabPage("Analyse");
|
||||
var top = new FlowLayoutPanel { Dock = DockStyle.Top, Height = 36, Padding = new Padding(8, 6, 8, 0) };
|
||||
top.Controls.Add(new Label { Text = "Profil", AutoSize = true, Margin = new Padding(0, 8, 4, 0) });
|
||||
top.Controls.Add(_profile);
|
||||
_ask.Click += async (_, _) => await AskAsync();
|
||||
var qPanel = new Panel { Dock = DockStyle.Top, Height = 70, Padding = new Padding(8, 2, 8, 4) };
|
||||
qPanel.Controls.Add(_question);
|
||||
var askPanel = new Panel { Dock = DockStyle.Top, Height = 34, Padding = new Padding(8, 0, 8, 0) };
|
||||
askPanel.Controls.Add(_ask);
|
||||
var answerPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8) };
|
||||
answerPanel.Controls.Add(_answer);
|
||||
tabChat.Controls.Add(answerPanel);
|
||||
tabChat.Controls.Add(askPanel);
|
||||
tabChat.Controls.Add(qPanel);
|
||||
tabChat.Controls.Add(top);
|
||||
|
||||
// ── Tab: Dossier-Browser ──
|
||||
var tabDossier = new TabPage("Dossier-Browser");
|
||||
_signals.SelectionChanged += (_, _) => ShowSelectedDossier();
|
||||
var refreshSignals = new Button { Text = "Signale laden", Dock = DockStyle.Top, Height = 28 };
|
||||
refreshSignals.Click += (_, _) => LoadSignals();
|
||||
var left = new Panel { Dock = DockStyle.Left, Width = 360 };
|
||||
left.Controls.Add(_signals);
|
||||
left.Controls.Add(refreshSignals);
|
||||
var dossierPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(8) };
|
||||
dossierPanel.Controls.Add(_dossier);
|
||||
tabDossier.Controls.Add(dossierPanel);
|
||||
tabDossier.Controls.Add(left);
|
||||
|
||||
// ── Tab: Berichte ──
|
||||
var tabReports = new TabPage("Berichte");
|
||||
var refreshReports = new Button { Text = "Berichte laden", Dock = DockStyle.Top, Height = 28 };
|
||||
refreshReports.Click += (_, _) => LoadReports();
|
||||
tabReports.Controls.Add(_reportsGrid);
|
||||
tabReports.Controls.Add(refreshReports);
|
||||
|
||||
// ── Tab: Settings (Info) ──
|
||||
var tabSettings = new TabPage("Settings");
|
||||
tabSettings.Controls.Add(new Label
|
||||
{
|
||||
Dock = DockStyle.Fill, Padding = new Padding(16),
|
||||
Text =
|
||||
"Supervisor – read-only Analyse/Forensik über alle Module.\n\n" +
|
||||
"OpenRouter-Key: env IBKRTRADER_OPENROUTER_KEY oder Datei 'openrouter.key' (gitignored).\n" +
|
||||
$" Status: {(string.IsNullOrEmpty(OpenRouterClient.DefaultApiKeyProvider()) ? "NICHT gesetzt – Chat nicht verfügbar" : "gesetzt")}\n\n" +
|
||||
"Tagesbericht (opt-in): env IBKRTRADER_SUPERVISOR_DAILY = Stunde 0–23.\n" +
|
||||
"MCP-Light (opt-in): env IBKRTRADER_MCP_PORT = Port (bindet nur 127.0.0.1).\n\n" +
|
||||
"Sicherheit: OpenRouter ist ein bewusst freigegebener externer Datenempfänger. Es werden nur\n" +
|
||||
"Analyse-Daten der Tools gesendet, niemals Secrets. Kein Tool kann handeln oder schreiben."
|
||||
});
|
||||
|
||||
tabs.TabPages.AddRange(new[] { tabChat, tabDossier, tabReports, tabSettings });
|
||||
Controls.Add(tabs);
|
||||
}
|
||||
|
||||
// ── Analyse ──
|
||||
|
||||
private async Task AskAsync()
|
||||
{
|
||||
var question = _question.Text.Trim();
|
||||
if (string.IsNullOrEmpty(question)) return;
|
||||
|
||||
_ask.Enabled = false;
|
||||
_answer.Clear();
|
||||
var profile = SupervisorProfiles.ByName(_profile.Text);
|
||||
var progress = new Progress<string>(s => AppendLine(s));
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _agent.AskAsync(question, profile: profile, progress: progress);
|
||||
AppendLine("");
|
||||
AppendLine("─── Antwort ───");
|
||||
AppendLine(result.Answer);
|
||||
|
||||
_reports.Insert(new SupervisorReport
|
||||
{
|
||||
Profile = profile.Name,
|
||||
Model = SupervisorAgent.DefaultModel,
|
||||
Question = question,
|
||||
Answer = result.Answer,
|
||||
ToolCallsJson = JsonSerializer.Serialize(result.ToolInvocations.Select(i => new { i.Tool, i.Arguments })),
|
||||
ToolCallCount = result.ToolInvocations.Count,
|
||||
PromptTokens = result.PromptTokens,
|
||||
CompletionTokens = result.CompletionTokens
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppendLine("");
|
||||
AppendLine($"FEHLER: {ex.Message}");
|
||||
_logger.Warn("Supervisor", $"Analyse fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ask.Enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendLine(string text)
|
||||
{
|
||||
if (_answer.InvokeRequired) { _answer.BeginInvoke(() => AppendLine(text)); return; }
|
||||
_answer.AppendText(text + "\n");
|
||||
_answer.ScrollToCaret();
|
||||
}
|
||||
|
||||
// ── Dossier ──
|
||||
|
||||
private void LoadSignals()
|
||||
{
|
||||
try { _signals.DataSource = _dossiers.RecentSignals(200); }
|
||||
catch (Exception ex) { _logger.Warn("Supervisor", $"Signale laden fehlgeschlagen: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private void ShowSelectedDossier()
|
||||
{
|
||||
if (_signals.CurrentRow?.DataBoundItem is not SignalSummary s) return;
|
||||
try { _dossier.Text = DossierBuilder.ToMarkdown(_dossiers.BuildForSignal(s.SignalId)); }
|
||||
catch (Exception ex) { _dossier.Text = $"FEHLER: {ex.Message}"; }
|
||||
}
|
||||
|
||||
// ── Berichte ──
|
||||
|
||||
private void LoadReports()
|
||||
{
|
||||
try
|
||||
{
|
||||
_reportsGrid.DataSource = _reports.GetRecent(100)
|
||||
.Select(r => new { r.CreatedAt, r.Profile, r.Model, r.Question, r.ToolCallCount, r.PromptTokens, r.CompletionTokens })
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex) { _logger.Warn("Supervisor", $"Berichte laden fehlgeschlagen: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
|
||||
namespace IBKRTrader.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Baut das gemeinsame Fenster-Menü, das auf JEDEM Fenster erscheint und das Wechseln zwischen allen
|
||||
/// Fenstern (Launcher + Core + Module) erlaubt. Es nutzt nur den Core-Contract
|
||||
/// <see cref="IModuleUiHost"/> und funktioniert deshalb aus jedem Fenster.
|
||||
///
|
||||
/// <para>Lag früher im Core. Verschoben in die Shell, weil er reine WinForms-Umsetzung ist – der
|
||||
/// Core soll keine UI-Abhängigkeit tragen (Linux-Portierung). Die Avalonia-Shell bekommt ein
|
||||
/// eigenes Gegenstück gegen denselben Contract.</para>
|
||||
/// </summary>
|
||||
public static class WindowMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// Verdrahtet einen <see cref="MenuStrip"/> mit der Fensterliste: füllt ihn sofort und baut ihn bei
|
||||
/// jeder Offen-Status-Änderung neu auf. Die Registrierung wird beim Entsorgen sauber gelöst.
|
||||
/// </summary>
|
||||
public static void Wire(MenuStrip menu, IModuleUiHost host, string? currentViewId)
|
||||
{
|
||||
void Refresh()
|
||||
{
|
||||
if (menu.IsDisposed) return;
|
||||
if (menu.IsHandleCreated && menu.InvokeRequired)
|
||||
{
|
||||
try { menu.BeginInvoke((Action)(() => Populate(menu, host, currentViewId))); }
|
||||
catch { /* Fenster wird gerade geschlossen */ }
|
||||
return;
|
||||
}
|
||||
Populate(menu, host, currentViewId);
|
||||
}
|
||||
|
||||
Populate(menu, host, currentViewId);
|
||||
host.OpenStateChanged += Refresh;
|
||||
menu.Disposed += (_, _) => host.OpenStateChanged -= Refresh;
|
||||
}
|
||||
|
||||
/// <summary>Baut die Menüleiste komplett neu auf (Launcher, alle Views nebeneinander, Aktion rechts).</summary>
|
||||
public static void Populate(MenuStrip menu, IModuleUiHost host, string? currentViewId)
|
||||
{
|
||||
menu.Items.Clear();
|
||||
|
||||
var launcher = new ToolStripMenuItem("Launcher") { Checked = currentViewId == null };
|
||||
if (currentViewId == null) launcher.Font = new Font(launcher.Font, FontStyle.Bold);
|
||||
launcher.Click += (_, _) => host.ActivateMain();
|
||||
menu.Items.Add(launcher);
|
||||
|
||||
foreach (var view in host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title))
|
||||
{
|
||||
bool isCurrent = view.Id == currentViewId;
|
||||
var icon = ViewIcons.Resolve(view.IconKey);
|
||||
var item = new ToolStripMenuItem(view.Title)
|
||||
{
|
||||
Image = icon,
|
||||
ImageScaling = ToolStripItemImageScaling.SizeToFit,
|
||||
DisplayStyle = icon != null
|
||||
? ToolStripItemDisplayStyle.ImageAndText
|
||||
: ToolStripItemDisplayStyle.Text,
|
||||
Checked = isCurrent || host.IsOpen(view.Id)
|
||||
};
|
||||
if (isCurrent) item.Font = new Font(item.Font, FontStyle.Bold);
|
||||
string id = view.Id;
|
||||
item.Click += (_, _) => host.OpenView(id);
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
|
||||
// Kontextabhängige rechte Aktion: nur der Launcher darf die App beenden; jedes andere Fenster
|
||||
// bietet nur „Fenster schließen" (kein App-Shutdown, Module laufen weiter).
|
||||
if (currentViewId == null)
|
||||
{
|
||||
var exit = new ToolStripMenuItem("Beenden") { Alignment = ToolStripItemAlignment.Right };
|
||||
exit.Click += (_, _) => host.RequestShutdown();
|
||||
menu.Items.Add(exit);
|
||||
}
|
||||
else
|
||||
{
|
||||
var close = new ToolStripMenuItem("Fenster schließen") { Alignment = ToolStripItemAlignment.Right };
|
||||
close.Click += (_, _) => menu.FindForm()?.Close();
|
||||
menu.Items.Add(close);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user