using System.Collections.ObjectModel;
using XFEEDToolBox.Core.Presentation;
using System.Diagnostics;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Dispatching;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage.Pickers;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Planning;
using XFEEDToolBox.Core.Services;
using XFEEDToolBox.Core.Native;
using XFEEDToolBox.Views;
using XFEEDToolBox.Core.Session;
using XFEEDToolBox.Services;
namespace XFEEDToolBox.ViewModels;
public partial class ToolboxViewModel : ViewModelBase, IDisposable
{
private readonly ToolboxRuntime runtime;
private readonly DispatcherQueue dispatcher;
private readonly DispatcherQueueTimer timer;
private bool initialized, refreshing, revertingDemo;
private int dirty, marketPage;
private long? lastMarketFilterId;
private long marketRequest;
private readonly CancellationTokenSource lifetime = new();
private RunProgress? lastRun;
private GameEnvironment? gameEnvironment;
private readonly GameSessionController sessions;
private WindowsGlobalHotkeys? hotkeys;
private CancellationTokenSource? sessionStop;
public PlayerState CurrentPlayer => DemoMode ? DemoData.Player : runtime.Player;
public string AppVersionLabel=>typeof(App).Assembly.GetName().Version is {} version?$"XFE ED TOOLBOX / {version.Major}.{version.Minor}.{version.Build}":"XFE ED TOOLBOX";
[ObservableProperty] public partial bool DemoMode { get; set; }
[ObservableProperty] public partial bool IsBusy { get; set; }
[ObservableProperty] public partial bool RunActive { get; set; }
[ObservableProperty] public partial bool SessionBusy { get; set; }
[ObservableProperty, NotifyPropertyChangedFor(nameof(StartingGame),nameof(RestartingGame),nameof(ReloggingGame))]
public partial SessionOperation? ActiveSessionOperation { get; set; }
public bool StartingGame => ActiveSessionOperation == SessionOperation.Start;
public bool RestartingGame => ActiveSessionOperation == SessionOperation.Restart;
public bool ReloggingGame => ActiveSessionOperation == SessionOperation.Relog;
[ObservableProperty, NotifyPropertyChangedFor(nameof(HasSessionMessage))] public partial string SessionMessage { get; set; } = "";
public bool HasSessionMessage => !string.IsNullOrWhiteSpace(SessionMessage);
[ObservableProperty] public partial bool HotkeysEnabled { get; set; }
[ObservableProperty] public partial string RelogHotkey { get; set; } = "";
[ObservableProperty] public partial string RestartHotkey { get; set; } = "";
[ObservableProperty] public partial string HotkeyStatus { get; set; } = "全局快捷键未启用。";
[ObservableProperty] public partial bool HasError { get; set; }
[ObservableProperty] public partial string ErrorMessage { get; set; } = "";
[ObservableProperty] public partial string StatusText { get; set; } = "正在读取本机数据";
[ObservableProperty] public partial string Commander { get; set; } = "等待 Journal";
[ObservableProperty] public partial string Ship { get; set; } = "—";
[ObservableProperty] public partial string CreditsText { get; set; } = "—";
[ObservableProperty] public partial string CargoText { get; set; } = "—";
[ObservableProperty] public partial string MaterialTotalText { get; set; } = "—";
[ObservableProperty] public partial string MaterialKindsText { get; set; } = "等待材料快照";
[ObservableProperty] public partial string PlayerSnapshotText { get; set; } = "正在读取本机指挥官数据";
[ObservableProperty] public partial string BalanceNote { get; set; } = "现金由 LoadGame 与后续交易事件推导";
[ObservableProperty] public partial string SystemName { get; set; } = "等待位置数据";
[ObservableProperty] public partial string StationName { get; set; } = "—";
[ObservableProperty] public partial string DockingText { get; set; } = "未连接游戏";
[ObservableProperty] public partial string CargoNote { get; set; } = "货舱数据待更新";
[ObservableProperty] public partial string CoverageText { get; set; } = "0 个站点";
[ObservableProperty] public partial string QuoteCountText { get; set; } = "0";
[ObservableProperty] public partial string LatestText { get; set; } = "尚无行情";
[ObservableProperty] public partial string SourceNote { get; set; } = "EDDN 增量行情 + 本地 Journal";
[ObservableProperty] public partial string EddnCountText { get; set; } = "0";
[ObservableProperty] public partial string MarketSearch { get; set; } = "";
[ObservableProperty] public partial int MarketSortIndex { get; set; }
[ObservableProperty] public partial bool OnlyCurrentStation { get; set; }
[ObservableProperty] public partial string MarketPageText { get; set; } = "第 1 页";
public LiveCollection<MarketLiveRow,MarketRow,(long,string)> MarketRows { get; } = new(x => (x.Station.MarketId,x.Quote.Commodity),x => new(x));
public LiveCollection<StationLiveRow,Station,long> Stations { get; } = new(x => x.MarketId,x => new(x));
[ObservableProperty] public partial int InventoryTab { get; set; }
[ObservableProperty] public partial string InventorySearch { get; set; } = "";
public LiveCollection<InventoryLiveRow,InventoryItem,(string,string,long?)> InventoryRows { get; } = new(x => (x.Group,x.Key,x.MissionId),x => new(x));
public LiveCollection<ShipLiveRow,ShipRecord,long> Ships { get; } = new(x => x.ShipId,x => new(x));
[ObservableProperty] public partial ShipLiveRow? SelectedShip { get; set; }
public LiveCollection<ModuleLiveRow,FittedModule,string> FittedModules { get; } = new(x => x.Slot,x => new(x));
public LiveCollection<StoredModuleLiveRow,StoredModule,long> StoredModules { get; } = new(x => x.StorageSlot,x => new(x));
public LiveCollection<ReadCheckLiveRow,ReadVerificationItem,string> ReadChecks { get; } = new(x => x.Category,x => new(x));
[ObservableProperty] public partial string FleetSummary { get; set; } = "等待船队与装配数据";
[ObservableProperty] public partial string SelectedShipSummary { get; set; } = "选择一艘飞船查看最后记录的配置。";
[ObservableProperty] public partial string CurrentShipSummary { get; set; } = "等待 Loadout";
[ObservableProperty] public partial string CockpitSummary { get; set; } = "等待 Status.json";
[ObservableProperty] public partial string ReadSummary { get; set; } = "正在核对读取覆盖范围";
[ObservableProperty] public partial string ReadReportPath { get; set; } = "验收报告保存在工具箱自己的数据目录。";
public LiveCollection<ActivityLiveRow,ActivityEntry,long> Activity { get; } = new(x => x.Id,x => new(x));
[ObservableProperty] public partial StationLiveRow? SelectedStart { get; set; }
[ObservableProperty] public partial double Cash { get; set; } = 1000000;
[ObservableProperty] public partial double Reserve { get; set; } = 100000;
[ObservableProperty] public partial double Hold { get; set; } = 64;
[ObservableProperty] public partial double JumpRange { get; set; } = 20;
[ObservableProperty] public partial double MaxDistance { get; set; } = 60;
[ObservableProperty] public partial double Radius { get; set; } = 500;
[ObservableProperty] public partial double MaxLegs { get; set; } = 4;
[ObservableProperty] public partial double MaxAgeHours { get; set; } = 6;
[ObservableProperty] public partial double MaxArrival { get; set; } = 10000;
[ObservableProperty] public partial double JumpMinutes { get; set; } = 1;
[ObservableProperty] public partial double DockMinutes { get; set; } = 4;
[ObservableProperty] public partial double CostPerJump { get; set; } = 100;
[ObservableProperty] public partial double CostPerMinute { get; set; }
[ObservableProperty] public partial bool Loop { get; set; } = true;
[ObservableProperty] public partial bool IncludePlanetary { get; set; }
[ObservableProperty] public partial bool IncludeCarriers { get; set; }
[ObservableProperty] public partial bool AllowUnknown { get; set; }
[ObservableProperty] public partial int RiskIndex { get; set; } = 1;
[ObservableProperty] public partial int ObjectiveIndex { get; set; }
[ObservableProperty] public partial int PadIndex { get; set; } = 2;
[ObservableProperty] public partial RoutePlan? Plan { get; set; }
[ObservableProperty] public partial TradeLeg? SelectedLeg { get; set; }
[ObservableProperty] public partial string PlanProfit { get; set; } = "—";
[ObservableProperty] public partial string PlanHourly { get; set; } = "—";
[ObservableProperty] public partial string PlanDuration { get; set; } = "—";
[ObservableProperty] public partial string PlanInfo { get; set; } = "选择起点和约束,计算你的下一趟航程。";
[ObservableProperty] public partial string PlanWarnings { get; set; } = "路线估计基于已知行情,出发前需确认游戏中的报价和航线。";
[ObservableProperty] public partial int ExecutionModeIndex { get; set; }
[ObservableProperty] public partial string ExecutionHint { get; set; } = "模拟演练只展示步骤,不向游戏发送输入。";
[ObservableProperty] public partial string RunStatus { get; set; } = "等待路线";
[ObservableProperty] public partial string RunMessage { get; set; } = "输入投入资金并规划路线,选择方案后开始执行。";
[ObservableProperty] public partial string ActualProfit { get; set; } = "0 Cr";
[ObservableProperty] public partial string ProfitLabel { get; set; } = "已确认净利润";
[ObservableProperty] public partial string ProfitDetails { get; set; } = "按实际成交和停靠服务回执更新。";
[ObservableProperty] public partial double RunPercent { get; set; }
[ObservableProperty] public partial string NextDestination { get; set; } = "—";
[ObservableProperty] public partial string JournalDirectory { get; set; } = "";
[ObservableProperty] public partial string EddnEndpoint { get; set; } = "";
[ObservableProperty] public partial bool EddnOnStartup { get; set; }
[ObservableProperty] public partial string ImportSystemName { get; set; } = "";
[ObservableProperty] public partial bool ImportNearby { get; set; } = true;
[ObservableProperty] public partial string ImportStatus { get; set; } = "可按星系补全,或导入 Spansh JSON / GZip 快照。";
[ObservableProperty] public partial string EnvironmentText { get; set; } = "正在自动读取本机舰船与游戏配置";
public string AcceptanceMode => "数据只读接入 · 会话操作由主页按钮或已启用的快捷键触发";
public string DataDirectory => runtime.DataDirectory;
internal bool OfflinePreview{get;}
public ToolboxViewModel(ToolboxRuntime runtime, DispatcherQueue dispatcher,bool offlinePreview=false)
{
galaxyLifetimeToken=lifetime.Token;
OfflinePreview=offlinePreview;
this.runtime = runtime; this.dispatcher = dispatcher;
systemNavigator=runtime.FlightAutomation.Local;systemNavigator.Progress+=OnSystemFlightProgress;
sessions = new(new WindowsGameSessionDriver(() => runtime.Player, () => PluginGameExecutable,
() => PluginRelogReady, ReportRelogProgress, EnsureNativeFlightProtectionAsync), () => RunActive||NavigationActive||SystemFlightActive||NavigationRouteBusy);
sessions.Progress += p => dispatcher.TryEnqueue(() => { if (p.Message.Length > 0) { SessionMessage = p.Message; runtime.Log("会话", p.Message); } });
JournalDirectory = runtime.Settings.JournalDirectory; EddnEndpoint = runtime.Settings.EddnEndpoint;
EddnOnStartup = runtime.Settings.ConnectEddnOnStart;
HotkeysEnabled = runtime.Settings.Hotkeys.Enabled; RelogHotkey = runtime.Settings.Hotkeys.Relog; RestartHotkey = runtime.Settings.Hotkeys.Restart;
InitializePluginSettings();
InitializeTranslationSettings();
InitializeProfile();
InitializeNavigation();
InitializeMarketCacheSettings();
NavigationRouteOptions=runtime.Settings.NavigationRouteOptions;
var r = runtime.Settings.Planner;
Reserve = (double)r.ReserveCredits; Hold = r.CargoCapacity; JumpRange = r.LadenJumpRangeLy;
MaxDistance = r.MaxLegDistanceLy; Radius = r.SearchRadiusLy; MaxLegs = r.MaxLegs; MaxAgeHours = r.MaxMarketAgeMinutes / 60.0;
MaxArrival = r.MaxArrivalDistanceLs; Loop = r.Loop; RiskIndex = (int)r.Risk; ObjectiveIndex = (int)r.Objective;
PadIndex = Math.Max(0, (int)r.RequiredPad - 1); IncludePlanetary = r.IncludePlanetary; IncludeCarriers = r.IncludeCarriers; AllowUnknown = r.AllowUnknownMetadata;
JumpMinutes = r.MinutesPerJump; DockMinutes = r.DockingMinutes; CostPerJump = (double)r.CostPerJump; CostPerMinute = (double)r.CostPerMinute;
InitializeTradePlanningOptions(r);
InitializeTradeRegionScope();
runtime.DataChanged += OnDataChanged;
runtime.RunChanged += OnRunChanged;
runtime.LiveJournalEvent+=OnShieldJournal;
timer = dispatcher.CreateTimer(); timer.Interval = TimeSpan.FromMilliseconds(200); timer.IsRepeating=false;
timer.Tick += async (_, _) =>
{
if(!initialized || lifetime.IsCancellationRequested)return;
if(refreshing){timer.Start();return;}
var changes=(RuntimeChange)Interlocked.Exchange(ref dirty,0);
if(changes!=RuntimeChange.None)await RefreshDataAsync(changes);
if(Volatile.Read(ref dirty)!=0)timer.Start();
};
}
private void OnRuntimeChanged() => OnDataChanged(RuntimeChange.All);
private void OnDataChanged(RuntimeChange changes)
{
if(lifetime.IsCancellationRequested)return;
Interlocked.Or(ref dirty,(int)changes);
dispatcher.TryEnqueue(()=>{if(initialized&&!timer.IsRunning&&!lifetime.IsCancellationRequested)timer.Start();});
}
private static IReadOnlyList<T> StableSequence<T>(IReadOnlyList<T> current,IEnumerable<T> next)
{ var values=next.ToArray();return current.SequenceEqual(values)?current:values; }
private void OnRunChanged(RunProgress progress) => dispatcher.TryEnqueue(() =>
{
if (automaticTradeStop is not null) return;
lastRun = progress;
RunActive = progress.State is RunState.Running or RunState.Paused;
RunStatus = progress.State switch { RunState.Running => "执行中", RunState.Paused => "已暂停", RunState.Completed => "已完成", RunState.Stopped => "已停止", RunState.Failed => "需要处理", _ => "等待路线" };
RunMessage = progress.Message;
PauseRunCommand.NotifyCanExecuteChanged();ResumeRunCommand.NotifyCanExecuteChanged();
ActualProfit = $"{progress.ActualProfit:N0} Cr";
TradeConfirmedProfit=progress.IsSimulation?"0 Cr":ActualProfit;
if(!progress.IsSimulation&&progress.State==RunState.Completed&&!plannedRoundCounted){CompletedTradeRounds++;plannedRoundCounted=true;}
ProfitLabel = progress.IsSimulation ? "模拟预计净收益" : "已确认净利润";
var costs = progress.Accounting;
ProfitDetails = progress.IsSimulation ? "规划估计值,未发生实际交易。"
: $"燃油 {costs.Fuel:N0} · 维修 {costs.Repairs:N0} · 补给 {costs.Rearm:N0} · 其他支出 {costs.OtherExpenses:N0} Cr\n在途货物成本 {costs.CargoInvestment:N0} Cr · 现金变动 {costs.CashChange:+#,0;-#,0;0} Cr";
RunPercent = progress.State == RunState.Completed ? 100 : progress.TotalLegs == 0 ? 0 : (progress.Leg - 1d) / progress.TotalLegs * 100;
NextDestination = Plan?.Legs.ElementAtOrDefault(Math.Max(0, progress.Leg - 1))?.To.DisplayName ?? "—";
RefreshTradePlanCommands(); CalculateCommand.NotifyCanExecuteChanged();
});
public async Task InitializeAsync()
{
initialized = true;
Interlocked.Exchange(ref dirty,0);
await RefreshAsync();
await LoadGalaxyCacheAsync();
// If the saved destination is already reached, present the route the
// player has subsequently plotted in game. This never replots the game.
if(CurrentPlayer.SystemName.Equals(NavigationDestination.Trim(),StringComparison.OrdinalIgnoreCase)
&&CurrentPlayer.Navigation.Route.LastOrDefault() is {} plotted&&plotted.SystemAddress!=CurrentPlayer.Navigation.CurrentSystemAddress)
NavigationDestination=plotted.Name;
RefreshNavigation(CurrentPlayer);
await RefreshUpgradeListAsync();
UsePlayerValues();
if(!OfflinePreview)
{
await ReadEnvironmentAsync();
await ReadPluginStatusAsync();
await ReadTranslationAsync();
await RefreshWalletAsync();
await InitializeAutomaticTradeAsync();
}
if(runtime.Settings.ShieldZeroRestartEnabled&&sessionAutomationAllowed)ShieldZeroRestartEnabled=true;
StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
}
partial void OnDemoModeChanged(bool value)
{
if (revertingDemo) return;
if (RunActive || SessionBusy) { ShowError("请先停止执行或会话操作,再切换数据模式。"); revertingDemo = true; DemoMode = !value; revertingDemo = false; return; }
SetTradeCandidates([]); SelectedStart = null; marketPage = 0;
UsePlayerValues(); OnRuntimeChanged();
StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
}
partial void OnMarketSearchChanged(string value) { CancelMarketRead();marketPage = 0; Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
partial void OnOnlyCurrentStationChanged(bool value) { CancelMarketRead();marketPage = 0; Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
partial void OnMarketSortIndexChanged(int value) { CancelMarketRead();marketPage = 0; Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
partial void OnInventorySearchChanged(string value) => OnDataChanged(RuntimeChange.Player);
partial void OnInventoryTabChanged(int value) => OnDataChanged(RuntimeChange.Player);
partial void OnSelectedShipChanged(ShipLiveRow? value)
{
FittedModules.Apply(value?.Modules ?? []);
SelectedShipSummary = value == null ? "没有飞船装配记录。" : $"{value.DisplayName} · {value.Ident}\n{value.Location} · {value.ConfigurationStatus}\n货舱 {value.CargoCapacity?.ToString() ?? "—"} t · 日志最大跳距 {value.MaxJumpRange?.ToString("F2") ?? "—"} ly · 重购 {value.Rebuy?.ToString("N0") ?? "—"} Cr\n最大跳距不是满载跳距,规划时仍需按载重校准。";
}
partial void OnPlanChanged(RoutePlan? value)
{
NotifyTradePlanningSummary();
RefreshTradePlanCommands();
if (RunActive) return;
RunStatus = value?.Legs.Count > 0 ? "待执行" : "等待路线";
RunMessage = value?.Legs.Count > 0 ? TradeHasExistingCargo?"方案已选定;请先处理现有货物,再开始自动执行。":"已选择路线,点击开始执行后启动。" : "输入投入资金并规划路线,再选择方案开始执行。";
NextDestination = value?.Legs.FirstOrDefault()?.To.DisplayName ?? "—";
}
partial void OnExecutionModeIndexChanged(int value) => ExecutionHint = value switch
{
0 => "模拟演练只展示步骤,不向游戏发送输入。",
1 => "你在游戏中操作;工具按 Journal 自动确认买入、到站、卖出并推进路线。",
_ => "自动执行由工具箱完成采购、航行、出售及站内维护,并核对实际回执。"
};
public void ShowError(string message) => dispatcher.TryEnqueue(() => { ErrorMessage = message; HasError = true; });
private const string MarketCacheFailureMessage="本地商品缓存恢复失败,行情暂不可用。飞船状态与导航继续更新,原始缓存和诊断记录已保留。";
[RelayCommand] private Task RefreshAsync() => RefreshDataAsync(RuntimeChange.All);
private async Task RefreshDataAsync(RuntimeChange changes)
{
if(!initialized)return;
if(refreshing){OnDataChanged(changes);return;}
refreshing=true;
try
{
var demo=DemoMode;var p=CurrentPlayer;
if(OnlyCurrentStation&&lastMarketFilterId!=p.MarketId)changes|=RuntimeChange.Markets;
if((changes&RuntimeChange.Markets)!=0)
{
try
{
var search=MarketSearch;var current=OnlyCurrentStation;var page=marketPage;var sort=(MarketSort)MarketSortIndex;var request=Volatile.Read(ref marketRequest);
var searchToken=BeginMarketRead();
var data=await Task.Run(()=>
{
if(demo)
{
var book=DemoData.CreateBook();
var rows=book.Quotes.Select(q=>new MarketRow(book.Stations.First(s=>s.MarketId==q.MarketId),q)).Where(x=>(!current||x.Station.MarketId==p.MarketId)&&(x.Name.Contains(search,StringComparison.OrdinalIgnoreCase)||x.Location.Contains(search,StringComparison.OrdinalIgnoreCase))).Skip(page*150).Take(150).ToArray();
return(Rows:(IReadOnlyList<MarketRow>)rows,Stations:book.Stations,Count:(Stations:(long)book.Stations.Count,Quotes:(long)book.Quotes.Count,Latest:(DateTimeOffset?)book.Quotes.Max(q=>q.CollectedAt)));
}
var rowsLive=current&&p.MarketId is null?[]:runtime.Markets.Search(search,current?p.MarketId:null,page*150,sort:sort,token:searchToken);
searchToken.ThrowIfCancellationRequested();
var stations=runtime.Markets.GetStations();
if(p.MarketId is {} id&&!stations.Any(s=>s.MarketId==id)&&runtime.Markets.GetStation(id) is {} origin)stations=new[]{origin}.Concat(stations).ToArray();
return(Rows:rowsLive,Stations:stations,Count:runtime.Markets.Statistics());
},lifetime.Token);
if(demo!=DemoMode){OnRuntimeChanged();return;}
if(request!=Volatile.Read(ref marketRequest)){OnDataChanged(changes);return;}
lastMarketFilterId=p.MarketId;
MarketRows.Apply(data.Rows,MarketSortIndex!=0);
MarketPageText=$"第 {marketPage+1} 页 · 本页 {MarketRows.Count} 条";
var oldStart=SelectedStart?.MarketId;var oldDestination=AutomaticDestination?.MarketId;
var selectedStations=new[]{SelectedStart?.Record,AutomaticDestination?.Record,automaticTradeParameters?.Source,automaticTradeParameters?.Destination}.OfType<Station>();
Stations.Apply(data.Stations.Concat(selectedStations));
SelectedStart=Stations.FirstOrDefault(s=>s.MarketId==(oldStart??p.MarketId));
AutomaticDestination=Stations.FirstOrDefault(s=>s.MarketId==oldDestination);
NotifyTradePlanningSummary();
CoverageText=$"{data.Count.Stations:N0} 个已知站点";QuoteCountText=data.Count.Quotes.ToString("N0");
LatestText=data.Count.Latest is {} at?at.ToLocalTime().ToString("MM-dd HH:mm:ss"):"尚无行情";
RefreshMarketPresentation();
if(ErrorMessage==MarketCacheFailureMessage){ErrorMessage="";HasError=false;}
}
catch(OperationCanceledException)when(!lifetime.IsCancellationRequested)
{OnDataChanged(RuntimeChange.Markets);}
catch(TimeoutException)
{MarketQueryStatus="查询超时,仍显示上次有效结果。可限定当前站点或输入完整商品名后重试。";}
catch(Microsoft.Data.Sqlite.SqliteException error)when(error.SqliteErrorCode is 11 or 26)
{
runtime.Log("商品缓存",error.ToString());
try
{
await Task.Run(()=>runtime.Markets.RecoverAsync(lifetime.Token),lifetime.Token);
MarketPageText="商品缓存已恢复,正在重新读取";
OnDataChanged(RuntimeChange.Markets);
}
catch(Exception recoveryError)when(recoveryError is not OperationCanceledException)
{
MarketPageText="商品缓存恢复失败";
runtime.Log("商品缓存恢复",recoveryError.ToString());
ShowError(MarketCacheFailureMessage);
}
}
}
if((changes&RuntimeChange.Player)!=0)
{
RefreshTradePlanningPlayer(p);
OnPropertyChanged(nameof(AutomaticShipSummary));
RefreshRelogAvailability();
Commander=p.Commander;RefreshProfileName();Ship=string.IsNullOrEmpty(p.Ship)?"舰船数据未知":Core.Localization.ChineseNames.Ship(p.Ship);
RefreshProfileData(p);
CreditsText=p.Credits?.ToString("N0")??"—";CargoText=$"{p.CargoCount:N0} / {p.CargoCapacity:N0} t";
MaterialTotalText=p.Materials.Sum(m=>m.Count).ToString("N0");MaterialKindsText=$"{p.Materials.Count} 种已持有材料";
PlayerSnapshotText=$"本机状态 · {p.UpdatedAt.ToLocalTime():MM-dd HH:mm:ss}";
BalanceNote=p.CreditsReliable?"Journal 余额 · 交易后自动更新":"余额可能不完整 · 重新登录游戏可校准";
SystemName=string.IsNullOrWhiteSpace(p.SystemName)?"等待位置数据":p.SystemName;
StationName=string.IsNullOrEmpty(p.StationName)?"深空 / 航行中":p.StationName;
DockingText=demo?"内部界面测试":!p.InGame?"游戏会话已结束":p.Docked?"已停靠":"航行中";
CargoNote=$"可用货舱 {p.FreeCargo:N0} t · 任务货物保留";
var inventory=InventoryTab switch{1=>p.Materials,2=>p.Locker,3=>p.Backpack,_=>p.Inventory};
InventoryRows.Apply(inventory.Where(i=>i.Name.Contains(InventorySearch,StringComparison.OrdinalIgnoreCase)||i.Key.Contains(InventorySearch,StringComparison.OrdinalIgnoreCase)).OrderBy(i=>i.Name));
RefreshInventoryPresentation();
var selectedId=SelectedShip?.ShipId??p.CurrentShipId;
Ships.Apply(p.Ships);StoredModules.Apply(p.StoredModules);
SelectedShip=Ships.FirstOrDefault(x=>x.ShipId==selectedId)??Ships.FirstOrDefault();
OnSelectedShipChanged(SelectedShip);
FleetSummary=$"{Ships.Count} 艘已知飞船 · {Ships.Count(s=>s.ConfigurationAt!=null)} 艘有历史装配 · {StoredModules.Count} 个寄存模块";
var activeShip=p.Ships.FirstOrDefault(x=>x.ShipId==p.CurrentShipId);
CurrentShipSummary=activeShip is null?"当前飞船装配未读取":$"当前:{activeShip.DisplayName} · #{activeShip.ShipId}\n{activeShip.ConfigurationStatus}";
CockpitSummary=p.Status is {} cockpit?cockpit.FuelText+"\n"+cockpit.PipsText+$"\nFlags {cockpit.Flags} · Flags2 {cockpit.Flags2}":"尚未读取座舱状态";
RefreshEngineering(p);
await RefreshUpgradeListAsync();
RefreshNavigation(p);
if(ImportSystemName.Length==0&&p.SystemName.Length>0)ImportSystemName=p.SystemName;
}
if((changes&(RuntimeChange.Player|RuntimeChange.Markets))!=0)
{
ReadChecks.Apply(ReadAcceptance.Build(p,demo?null:runtime.Journal.LocalMarket));
ReadSummary=$"已解析 {runtime.Journal.EventsRead:N0} 条 Journal 事件 · 检测到变化时更新\n{runtime.Journal.DirectoryPath}\n每一项时间均为游戏最后写出时间,不代表全部数据当前有效。";
}
if((changes&(RuntimeChange.Connection|RuntimeChange.Markets))!=0)
{
EddnCountText=(runtime.Eddn?.MessagesReceived??0).ToString("N0");StatusText=runtime.Eddn?.Status??"EDDN 未连接 · 本地数据可用";
SourceNote="本地 Market + EDDN 增量行情 + Spansh 快照";
}
if((changes&RuntimeChange.Activity)!=0)Activity.Apply(runtime.Activity);
if((changes&RuntimeChange.GameEvents)!=0&&GameLogPageVisible&&gameLogPage==0)await RefreshGameEventsAsync();
if((changes&(RuntimeChange.Player|RuntimeChange.Markets|RuntimeChange.Connection))!=0)RefreshDashboard(p);
if((changes&(RuntimeChange.Player|RuntimeChange.Markets))!=0)await RefreshGalaxyAsync(p,(changes&RuntimeChange.Markets)!=0);
}
catch(OperationCanceledException)when(lifetime.IsCancellationRequested){}
catch(Exception e){runtime.Log("界面数据刷新",e.ToString());ShowError(e.Message);}
finally{refreshing=false;}
}
[RelayCommand]
private void UsePlayerValues()
{
RefreshTradePlanningPlayer(CurrentPlayer,force:true);
}
partial void OnSessionBusyChanged(bool value)
{
StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
RefreshTradePlanCommands(); CalculateCommand.NotifyCanExecuteChanged();
StartNavigationCommand.NotifyCanExecuteChanged();
StartRemoteJourneyCommand.NotifyCanExecuteChanged();
UpdateSystemControlAvailability();
}
partial void OnRunActiveChanged(bool value) { RefreshTradeExecutionTimer(value);RefreshTradePlanCommands();NotifyTradePlanningSummary(); PauseRunCommand.NotifyCanExecuteChanged();ResumeRunCommand.NotifyCanExecuteChanged();StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();StartNavigationCommand.NotifyCanExecuteChanged();UpdateSystemControlAvailability(); }
private bool CanManageSession() => initialized && !DemoMode && !RunActive && !SessionBusy&&!NavigationActive&&!SystemFlightActive&&!NavigationRouteBusy;
private bool CanRelog() => CanManageSession() && sessions.CanRelog;
[RelayCommand(CanExecute = nameof(CanManageSession))] private Task StartGameAsync() => RunSessionAsync(SessionOperation.Start);
[RelayCommand(CanExecute = nameof(CanRelog))] private Task RelogGameAsync() => RunSessionAsync(SessionOperation.Relog);
[RelayCommand(CanExecute = nameof(CanManageSession))] private Task RestartGameAsync() => RunSessionAsync(SessionOperation.Restart);
[RelayCommand] private void CancelSession() => sessionStop?.Cancel();
public void InitializeHotkeys(bool activate)
{
sessionAutomationAllowed=activate;
hotkeys = new(WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow), operation => dispatcher.TryEnqueue(() =>
{
if (!CanManageSession()) { SessionMessage = "当前状态不能执行会话操作。"; return; }
if (operation == SessionOperation.Relog && !sessions.CanRelog) { SessionMessage = RelogAvailabilityText; return; }
_ = RunSessionAsync(operation);
}));
if (!activate) { HotkeyStatus = "隐藏界面检查:未注册任何用户快捷键。"; return; }
try { hotkeys.Apply(runtime.Settings.Hotkeys); UpdateHotkeyStatus(); }
catch (Exception e) { HotkeyStatus = e.Message; ShowError(e.Message); }
}
private void UpdateHotkeyStatus()
{
var s = hotkeys?.Settings ?? new();
HotkeyStatus = !s.Enabled ? "全局快捷键已关闭。" : $"当前生效:重进 {(s.Relog.Length > 0 ? s.Relog : "未绑定")} · 大退 {(s.Restart.Length > 0 ? s.Restart : "未绑定")}。";
}
[RelayCommand] private async Task SaveHotkeysAsync()
{
if (hotkeys is null) return;
var previous = hotkeys.Settings;
try
{
hotkeys.Apply(new(HotkeysEnabled, RelogHotkey, RestartHotkey));
await runtime.SaveSettingsAsync(runtime.Settings with { Hotkeys = hotkeys.Settings });
RelogHotkey = hotkeys.Settings.Relog; RestartHotkey = hotkeys.Settings.Restart; UpdateHotkeyStatus();
}
catch (Exception e)
{
try { hotkeys.Apply(previous); } catch (Exception restore) { HotkeyStatus = "快捷键恢复失败:" + restore.Message; }
HotkeyStatus = "未应用本次修改:" + e.Message; ShowError(e.Message);
}
}
private async Task RunSessionAsync(SessionOperation operation)
{
if (!CanManageSession()) return;
ActiveSessionOperation=operation;SessionBusy = true; HasError = false;
using var operationStop = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
sessionStop = operationStop;
try { await sessions.RunAsync(operation, operationStop.Token); }
catch (OperationCanceledException) { SessionMessage = operation == SessionOperation.Relog ? "已取消后续重进操作,已提交给游戏的菜单操作仍会完成。" : "会话操作已取消。"; }
catch (Exception e) { ShowError(e.Message); }
finally { sessionStop = null; SessionBusy = false; ActiveSessionOperation=null; }
}
private bool CanCalculate() => !RunActive && !SessionBusy && !IsBusy&&!TradeRegionBusy&&TradeShipPadKnown;
private bool CanImportData() => !IsBusy;
partial void OnIsBusyChanged(bool value)
{
OnPropertyChanged(nameof(TradeSelectionEnabled));
RefreshTradePlanCommands();
CalculateCommand.NotifyCanExecuteChanged();ImportSystemCommand.NotifyCanExecuteChanged();
ImportSpanshCommand.NotifyCanExecuteChanged();ImportCaptureCommand.NotifyCanExecuteChanged();
}
[RelayCommand(IncludeCancelCommand = true, CanExecute = nameof(CanCalculate))]
private async Task CalculateAsync(CancellationToken token)
{
IsBusy = true; HasError = false;
var progress=BeginTradePlanningProgress(token);
try
{
RefreshTradePlanningPlayer(CurrentPlayer);
if(!double.IsFinite(Radius))Radius=runtime.Settings.MarketCacheRadiusLy;
await PrepareTradeRegionForPlanningAsync(token);
var request = BuildRequest();
var inputVersion=Volatile.Read(ref tradePlanningInputVersion);
var demo = DemoMode;
SetTradeCandidates([]);TradeEmptyTitle="正在比较路线";TradeEmptyDescription="正在核对范围内的行情、供需与飞船约束。";
TradePlanningStatus="正在比较可行路线与预计小时收益…";
IReadOnlyList<CommunityGoalInfo> goals=[];
if(request.CommunityGoalSupplyOnly)
{
ApplyTradePlanningProgress(new("核对 CG 供货条件",0,null,"只读确认有效活动、交付站与接收商品;未确认时不推荐普通路线。"));
goals=await ReadTradeCommunityGoalsForPlanningAsync(demo,token);
}
var book=demo?DemoData.CreateBook():await Task.Run(()=>runtime.Markets.LoadPlanningRegion(request,token:token,progress:progress),token);
var result = await Task.Run(() => new TradeRoutePlanner().CalculateCandidatesWithDiagnostics(book, request,3,token,progress,goals), token);
Interlocked.Increment(ref tradePlanningOperation);
if(!request.CommunityGoalSupplyOnly&&result.Candidates.Count>0)
{
ApplyTradePlanningProgress(new("核对CG活动目录",0,null,"最多等待三秒;活动未知不影响常规市场利润的比较。"));
goals=await ReadTradeCommunityGoalsForPlanningAsync(demo,token);
}
else if(request.CommunityGoalSupplyOnly&&result.Candidates.Count>0
&&(tradeCommunityGoalVerifiedAt is not {} readAt||DateTimeOffset.UtcNow-readAt>TimeSpan.FromMinutes(2)))
goals=await ReadTradeCommunityGoalsForPlanningAsync(demo,token);
token.ThrowIfCancellationRequested();
if (demo != DemoMode) return;
if(inputVersion!=Volatile.Read(ref tradePlanningInputVersion))
{SetTradePlanningIssue("资料已更新,请重新规划","飞船、货舱、位置或规划参数已在计算期间变化,请按当前数据重新规划。");return;}
SetTradeCandidates(result.Candidates,result.Diagnostics,goals);
runtime.Log("规划",JsonSerializer.Serialize(result.Diagnostics,ToolboxSettings.JsonOptions));
if (!demo) await runtime.SaveSettingsAsync(runtime.Settings with { Planner = request });
}
catch (OperationCanceledException) { SetTradePlanningIssue("规划已取消","本次规划已取消,点击规划路线可重新比较。"); }
catch (Exception e) { SetTradePlanningIssue("暂时无法生成方案",e.Message);ShowError(e.Message); }
finally { EndTradePlanningProgress();IsBusy = false; }
}
private PlannerRequest BuildRequest()
{
var player=CurrentPlayer;
var defaults=runtime.Settings.Planner;
static double Optional(double value,double fallback)=>double.IsFinite(value)?value:fallback;
var cash=Optional(Cash,(double)(player.Credits??0));var reserve=Optional(Reserve,(double)player.Rebuy);
var investment=Optional(TradeInvestment,Math.Max(0,cash-reserve));
if(investment<=0||investment>Math.Max(0,cash-reserve))throw new ArgumentException("投入资金须大于零,且不能超过留出运营与保险资金后的可用余额。");
var shipPad=ShipPadPolicy.RequiredPad(player.Ship);
if(shipPad==LandingPad.Unknown)throw new ArgumentException("当前飞船尺寸尚未识别,请等待真实飞船数据后再规划。");
var request = new PlannerRequest
{
StartMarketId = player.MarketId??0, Credits = (decimal)cash, ReserveCredits = Math.Max((decimal)reserve,(decimal)cash-(decimal)investment),
InvestmentCredits=investmentFollowsBalance?null:(decimal)investment,
CargoCapacity = checked((int)Optional(Hold,player.CargoCapacity)),OccupiedCargo=player.CargoCount,LadenJumpRangeLy = Optional(JumpRange,player.MaxJumpRange??defaults.LadenJumpRangeLy),JumpRangeIsEstimate=true,
CurrentPosition=player.Position,CurrentSystemName=player.SystemName,CurrentMarketId=player.MarketId,MaxLegDistanceLy = Optional(MaxDistance,defaults.MaxLegDistanceLy),
SearchRadiusLy = Optional(Radius,runtime.Settings.MarketCacheRadiusLy),SearchCenterPosition=EffectiveTradeRegionCenter,SearchCenterName=TradeRegionCenterText.Trim().Length==0?player.SystemName:resolvedTradeRegionName,
MaxLegs = checked((int)Optional(MaxLegs,defaults.MaxLegs)), Loop = Loop,CommunityGoalSupplyOnly=TradePlanningModeIndex==1,
CommunityGoalCommodityFilter=TradePlanningModeIndex==1?TradeSearchConstraints.ParseCommodityFilter(TradeCommoditySearchText):[],
SourceMarketIdFilter=tradeSourceSearchMarketId,DestinationMarketIdFilter=tradeDestinationSearchMarketId,
SourceSystemFilter=TradeSourceSystemText?.Trim()??"",SourceStationFilter=TradeSourceSearchStation?.Record.Name??TradeSourceSearchText?.Trim()??"",
DestinationSystemFilter=TradeDestinationSystemText?.Trim()??"",DestinationStationFilter=TradeDestinationSearchStation?.Record.Name??TradeDestinationSearchText?.Trim()??"",
Risk = RiskIndex is >=0 and <=2?(RiskTolerance)RiskIndex:defaults.Risk, Objective = ObjectiveIndex is >=0 and <=2?(RouteObjective)ObjectiveIndex:defaults.Objective,
ActualShipType=player.Ship,ShipRequiredPad=shipPad,AllowedSourceKinds=SelectedTradeSourceKinds==0?StationSourceKinds.AllKnown:SelectedTradeSourceKinds,
RequiredPad = (LandingPad)Math.Max((int)shipPad,PadIndex is >=0 and <=2?PadIndex+1:(int)shipPad), IncludePlanetary = true,
IncludeCarriers = true, AllowUnknownMetadata = AllowUnknown,
MaxMarketAgeMinutes = checked((int)(Optional(MaxAgeHours,defaults.MaxMarketAgeMinutes/60d) * 60)), MaxArrivalDistanceLs = Optional(MaxArrival,defaults.MaxArrivalDistanceLs),
MinutesPerJump = Optional(JumpMinutes,defaults.MinutesPerJump), DockingMinutes = Optional(DockMinutes,defaults.DockingMinutes), CostPerJump = (decimal)Optional(CostPerJump,(double)defaults.CostPerJump), CostPerMinute = (decimal)Optional(CostPerMinute,(double)defaults.CostPerMinute)
};
request.Validate(); return request;
}
private bool CanStartPlan() => Plan?.Legs.Count > 0 && !RunActive && !SessionBusy&&!NavigationActive;
[RelayCommand(CanExecute = nameof(CanStartPlan))]
private async Task StartPlanAsync(CancellationToken cancellationToken=default)
{
using var stop=CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token,cancellationToken);var token=stop.Token;
try
{
if (Plan == null) return;
if (DemoMode && ExecutionModeIndex != 0) throw new InvalidOperationException("演示沙盘仅支持模拟演练。");
if (ExecutionModeIndex is < 0 or > 2) throw new InvalidOperationException("请选择有效的执行方式。");
if(ExecutionModeIndex==2&&(!CurrentPlayer.Docked||CurrentPlayer.MarketId!=Plan.Legs[0].From.MarketId))
throw new InvalidOperationException("请先停靠 "+Plan.Legs[0].From.DisplayName+" 再执行此完整路线;前往起点的耗时目前只计入估算。");
BeginPlannedTradeMetrics();
var nativeDriver=ExecutionModeIndex==2?await CreateNativeTradeDriverAsync(Plan,token):null;
token.ThrowIfCancellationRequested();
AppShellPage.Current?.Navigate(typeof(PlannerPage));
RunActive = true;
await runtime.Runner.RunAsync(Plan, (ExecutionMode)ExecutionModeIndex, nativeDriver, token);
}
catch(OperationCanceledException){runtime.Log("执行","执行已取消。");}
catch (Exception e) { ShowError(e.Message); }
finally { RunActive = false; RefreshTradePlanCommands(); CalculateCommand.NotifyCanExecuteChanged(); }
}
private bool CanPauseRun()=>automaticTradeStop is null&&runtime.Runner.State==RunState.Running;
private bool CanResumeRun()=>automaticTradeStop is null&&runtime.Runner.State==RunState.Paused;
[RelayCommand(CanExecute=nameof(CanPauseRun))] private void PauseRun() => runtime.Runner.Pause();
[RelayCommand(CanExecute=nameof(CanResumeRun))] private void ResumeRun() { try { runtime.Runner.Resume(); } catch (Exception e) { ShowError(e.Message); } }
[RelayCommand] private void StopRun()
{
if (automaticTradeStop is { } stop)
{
RunStatus = "正在取消";
RunMessage = "正在停止跑商,核对已提交请求并释放控制。";
stop.Cancel();
return;
}
runtime.Runner.Stop();
}
[RelayCommand] private void PreviousMarketPage() { CancelMarketRead();marketPage = Math.Max(0, marketPage - 1); Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
[RelayCommand] private void NextMarketPage() { CancelMarketRead();if (MarketRows.Count == 150) marketPage++; Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
[RelayCommand] private void CopyDestination()
{
var leg = Plan?.Legs.ElementAtOrDefault(Math.Max(0, (lastRun?.Leg ?? 1) - 1));
if (leg == null) return;
var data = new DataPackage(); data.SetText(leg.To.SystemName); Clipboard.SetContent(data);
runtime.Log("导航", $"已复制目的星系:{leg.To.SystemName}");
}
[RelayCommand] private async Task ExportPlanAsync()
{
try
{
if (Plan == null) throw new InvalidOperationException("请先计算路线。");
var picker = new FileSavePicker { SuggestedFileName = $"ED-Trade-{DateTime.Now:yyyyMMdd-HHmm}" };
picker.FileTypeChoices.Add("路线 JSON", new[] { ".json" }); InitializePicker(picker);
if (await picker.PickSaveFileAsync() is { } file) await File.WriteAllTextAsync(file.Path, JsonSerializer.Serialize(Plan, ToolboxSettings.JsonOptions));
}
catch (Exception e) { ShowError(e.Message); }
}
[RelayCommand] private async Task ExportReadReportAsync()
{
try
{
if (DemoMode) throw new InvalidOperationException("请切换到实时接入后导出真实数据验收报告。");
var p = runtime.Player;
var cache = runtime.Markets.Statistics();
var report = new { generatedAt = DateTimeOffset.UtcNow, mode = "external-read-only", journalDirectory = runtime.Journal.DirectoryPath,
eventsRead = runtime.Journal.EventsRead, checks = ReadAcceptance.Build(p, runtime.Journal.LocalMarket),
environment = gameEnvironment, player = p with { CommanderId = "" },
localMarket = runtime.Journal.LocalMarket, publicMarketCache = new { cache.Stations, cache.Quotes, cache.Latest },
limits = new[] { "未使用过的远程飞船可能只有清单而没有装配", "历史装配不等于实时配置", "公共行情不覆盖所有站点的即时价格", "本报告不证明零封禁风险" } };
var folder = Path.Combine(runtime.DataDirectory, "reports"); Directory.CreateDirectory(folder);
var path = Path.Combine(folder, $"read-acceptance-{DateTime.Now:yyyyMMdd-HHmmss}.json");
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(report, ToolboxSettings.JsonOptions));
ReadReportPath = path; runtime.Log("验收", "已导出只读数据报告(不含账号认证信息)。");
}
catch (Exception e) { ShowError(e.Message); }
}
[RelayCommand] private async Task SaveSettingsAsync()
{
try
{
if (!Uri.TryCreate(EddnEndpoint, UriKind.Absolute, out var uri) || uri.Scheme != "tcp" || uri.Port <= 0) throw new ArgumentException("请输入有效的 EDDN TCP 地址。");
await runtime.SaveSettingsAsync(runtime.Settings with { JournalDirectory = JournalDirectory.Trim(), EddnEndpoint = EddnEndpoint.Trim(), ConnectEddnOnStart = EddnOnStartup });
OnRuntimeChanged();
}
catch (Exception e) { ShowError(e.Message); }
}
[RelayCommand] private async Task ToggleEddnAsync()
{
try { if (runtime.Eddn == null) runtime.StartEddn(); else await runtime.StopEddnAsync(); }
catch (Exception e) { ShowError(e.Message); }
}
[RelayCommand(IncludeCancelCommand = true,CanExecute=nameof(CanImportData))] private async Task ImportSystemAsync(CancellationToken token)
{
IsBusy = true;
try
{
var progress = new Progress<ImportProgress>(p => ImportStatus = $"{p.Message} · {p.Systems} 星系 / {p.Stations} 站点 / {p.Quotes:N0} 报价");
var result = ImportNearby ? await runtime.Spansh.ImportNearbyAsync(ImportSystemName.Trim(), 20, progress, token) : await runtime.Spansh.ImportSystemAsync(ImportSystemName.Trim(), progress, token);
ImportStatus = $"已处理 {result.Systems} 个星系、{result.Stations} 个站点;更新 {result.Quotes:N0} 条报价。保留源数据时间。";
runtime.Log("导入", ImportStatus); OnRuntimeChanged();
}
catch (OperationCanceledException) { ImportStatus = "导入已取消,已完成的数据保留。"; }
catch (Exception e) { ImportStatus = "导入未完成"; ShowError(e.Message); }
finally { IsBusy = false; }
}
[RelayCommand(CanExecute=nameof(CanImportData))] private Task ImportSpanshAsync(CancellationToken token)=>ImportFileAsync("spansh",token);
[RelayCommand(CanExecute=nameof(CanImportData))] private Task ImportCaptureAsync(CancellationToken token)=>ImportFileAsync("capture",token);
private async Task ImportFileAsync(string kind, CancellationToken token)
{
IsBusy = true;
try
{
var picker = new FileOpenPicker();
foreach (var ext in kind == "capture" ? new[] { ".zip" } : new[] { ".json", ".gz" }) picker.FileTypeFilter.Add(ext);
InitializePicker(picker);
if (await picker.PickSingleFileAsync().AsTask(token) is not { } file) return;
token.ThrowIfCancellationRequested();
var progress = new Progress<ImportProgress>(p => ImportStatus = $"{p.Message} · {p.Stations} 站点 / {p.Quotes:N0} 报价");
var result = kind == "capture" ? await Task.Run(() => CaptureImporter.ImportAsync(file.Path, runtime.Markets, progress, token), token) : await Task.Run(() => runtime.Spansh.ImportFileAsync(file.Path, progress: progress, token: token), token);
ImportStatus = $"完成:{result.Stations} 个站点,{result.Quotes:N0} 条报价。"; runtime.Log("导入", ImportStatus); OnRuntimeChanged();
}
catch (OperationCanceledException) { ImportStatus = "导入已取消,已完成的数据保留。"; }
catch (Exception e) { ShowError(e.Message); }
finally { IsBusy = false; }
}
[RelayCommand] private async Task ChooseJournalAsync()
{
var picker = new FolderPicker(); picker.FileTypeFilter.Add("*"); InitializePicker(picker);
if (await picker.PickSingleFolderAsync() is { } folder) JournalDirectory = folder.Path;
}
[RelayCommand] private async Task ReadEnvironmentAsync(CancellationToken cancellationToken=default)
{
using var stop=CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token,cancellationToken);var token=stop.Token;
try
{
gameEnvironment = await GameEnvironmentReader.ReadAsync(runtime.Player, token: token);
EnvironmentText = gameEnvironment.Summary + "\n" + string.Join("\n", gameEnvironment.Diagnostics);
}
catch(OperationCanceledException){EnvironmentText="环境读取已取消。";}
catch (Exception e) { ShowError(e.Message); }
}
[RelayCommand] private void OpenDataDirectory() => Process.Start(new ProcessStartInfo(runtime.DataDirectory) { UseShellExecute = true });
private static void InitializePicker(object picker) => WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow));
public void Dispose()
{
runtime.FlightAutomation.Progress-=OnFlightAutomationProgress;runtime.FlightAutomation.Stop();
runtime.Navigator.Progress-=OnNavigationProgress;runtime.Navigator.Stop();
systemNavigator.Progress-=OnSystemFlightProgress;systemNavigator.Stop();systemFlightStop?.Cancel();currentSystemTimer?.Stop();currentSystemSource?.Dispose();
if(shipProtection is {} protection)_=protection.DisposeAsync();
hotkeys?.Dispose(); sessionStop?.Cancel();
runtime.LiveJournalEvent-=OnShieldJournal;
walletTimer?.Stop();
tradeExecutionTimer?.Stop();
shipTelemetryTimer?.Stop();
timer.Stop(); runtime.DataChanged -= OnDataChanged; runtime.RunChanged -= OnRunChanged;
lifetime.Cancel(); lifetime.Dispose();
galaxyStreaming.Dispose();galaxyCellStreaming.Dispose();if(nativeGalaxy is not null&&galaxyNativeProgressHandler is not null)nativeGalaxy.RegionProgress-=galaxyNativeProgressHandler;
galaxyHttp.Dispose();
}
}
using System.Collections.ObjectModel;
using XFEEDToolBox.Core.Presentation;
using System.Diagnostics;
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Dispatching;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage.Pickers;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Planning;
using XFEEDToolBox.Core.Services;
using XFEEDToolBox.Core.Native;
using XFEEDToolBox.Views;
using XFEEDToolBox.Core.Session;
using XFEEDToolBox.Services;
namespace XFEEDToolBox.ViewModels;
public partial class ToolboxViewModel : ViewModelBase, IDisposable
{
private readonly ToolboxRuntime runtime;
private readonly DispatcherQueue dispatcher;
private readonly DispatcherQueueTimer timer;
private bool initialized, refreshing, revertingDemo;
private int dirty, marketPage;
private long? lastMarketFilterId;
private long marketRequest;
private readonly CancellationTokenSource lifetime = new();
private RunProgress? lastRun;
private GameEnvironment? gameEnvironment;
private readonly GameSessionController sessions;
private WindowsGlobalHotkeys? hotkeys;
private CancellationTokenSource? sessionStop;
public PlayerState CurrentPlayer => DemoMode ? DemoData.Player : runtime.Player;
public string AppVersionLabel=>typeof(App).Assembly.GetName().Version is {} version?$"XFE ED TOOLBOX / {version.Major}.{version.Minor}.{version.Build}":"XFE ED TOOLBOX";
[ObservableProperty] public partial bool DemoMode { get; set; }
[ObservableProperty] public partial bool IsBusy { get; set; }
[ObservableProperty] public partial bool RunActive { get; set; }
[ObservableProperty] public partial bool SessionBusy { get; set; }
[ObservableProperty, NotifyPropertyChangedFor(nameof(StartingGame),nameof(RestartingGame),nameof(ReloggingGame))]
public partial SessionOperation? ActiveSessionOperation { get; set; }
public bool StartingGame => ActiveSessionOperation == SessionOperation.Start;
public bool RestartingGame => ActiveSessionOperation == SessionOperation.Restart;
public bool ReloggingGame => ActiveSessionOperation == SessionOperation.Relog;
[ObservableProperty, NotifyPropertyChangedFor(nameof(HasSessionMessage))] public partial string SessionMessage { get; set; } = "";
public bool HasSessionMessage => !string.IsNullOrWhiteSpace(SessionMessage);
[ObservableProperty] public partial bool HotkeysEnabled { get; set; }
[ObservableProperty] public partial string RelogHotkey { get; set; } = "";
[ObservableProperty] public partial string RestartHotkey { get; set; } = "";
[ObservableProperty] public partial string HotkeyStatus { get; set; } = "全局快捷键未启用。";
[ObservableProperty] public partial bool HasError { get; set; }
[ObservableProperty] public partial string ErrorMessage { get; set; } = "";
[ObservableProperty] public partial string StatusText { get; set; } = "正在读取本机数据";
[ObservableProperty] public partial string Commander { get; set; } = "等待 Journal";
[ObservableProperty] public partial string Ship { get; set; } = "—";
[ObservableProperty] public partial string CreditsText { get; set; } = "—";
[ObservableProperty] public partial string CargoText { get; set; } = "—";
[ObservableProperty] public partial string MaterialTotalText { get; set; } = "—";
[ObservableProperty] public partial string MaterialKindsText { get; set; } = "等待材料快照";
[ObservableProperty] public partial string PlayerSnapshotText { get; set; } = "正在读取本机指挥官数据";
[ObservableProperty] public partial string BalanceNote { get; set; } = "现金由 LoadGame 与后续交易事件推导";
[ObservableProperty] public partial string SystemName { get; set; } = "等待位置数据";
[ObservableProperty] public partial string StationName { get; set; } = "—";
[ObservableProperty] public partial string DockingText { get; set; } = "未连接游戏";
[ObservableProperty] public partial string CargoNote { get; set; } = "货舱数据待更新";
[ObservableProperty] public partial string CoverageText { get; set; } = "0 个站点";
[ObservableProperty] public partial string QuoteCountText { get; set; } = "0";
[ObservableProperty] public partial string LatestText { get; set; } = "尚无行情";
[ObservableProperty] public partial string SourceNote { get; set; } = "EDDN 增量行情 + 本地 Journal";
[ObservableProperty] public partial string EddnCountText { get; set; } = "0";
[ObservableProperty] public partial string MarketSearch { get; set; } = "";
[ObservableProperty] public partial int MarketSortIndex { get; set; }
[ObservableProperty] public partial bool OnlyCurrentStation { get; set; }
[ObservableProperty] public partial string MarketPageText { get; set; } = "第 1 页";
public LiveCollection<MarketLiveRow,MarketRow,(long,string)> MarketRows { get; } = new(x => (x.Station.MarketId,x.Quote.Commodity),x => new(x));
public LiveCollection<StationLiveRow,Station,long> Stations { get; } = new(x => x.MarketId,x => new(x));
[ObservableProperty] public partial int InventoryTab { get; set; }
[ObservableProperty] public partial string InventorySearch { get; set; } = "";
public LiveCollection<InventoryLiveRow,InventoryItem,(string,string,long?)> InventoryRows { get; } = new(x => (x.Group,x.Key,x.MissionId),x => new(x));
public LiveCollection<ShipLiveRow,ShipRecord,long> Ships { get; } = new(x => x.ShipId,x => new(x));
[ObservableProperty] public partial ShipLiveRow? SelectedShip { get; set; }
public LiveCollection<ModuleLiveRow,FittedModule,string> FittedModules { get; } = new(x => x.Slot,x => new(x));
public LiveCollection<StoredModuleLiveRow,StoredModule,long> StoredModules { get; } = new(x => x.StorageSlot,x => new(x));
public LiveCollection<ReadCheckLiveRow,ReadVerificationItem,string> ReadChecks { get; } = new(x => x.Category,x => new(x));
[ObservableProperty] public partial string FleetSummary { get; set; } = "等待船队与装配数据";
[ObservableProperty] public partial string SelectedShipSummary { get; set; } = "选择一艘飞船查看最后记录的配置。";
[ObservableProperty] public partial string CurrentShipSummary { get; set; } = "等待 Loadout";
[ObservableProperty] public partial string CockpitSummary { get; set; } = "等待 Status.json";
[ObservableProperty] public partial string ReadSummary { get; set; } = "正在核对读取覆盖范围";
[ObservableProperty] public partial string ReadReportPath { get; set; } = "验收报告保存在工具箱自己的数据目录。";
public LiveCollection<ActivityLiveRow,ActivityEntry,long> Activity { get; } = new(x => x.Id,x => new(x));
[ObservableProperty] public partial StationLiveRow? SelectedStart { get; set; }
[ObservableProperty] public partial double Cash { get; set; } = 1000000;
[ObservableProperty] public partial double Reserve { get; set; } = 100000;
[ObservableProperty] public partial double Hold { get; set; } = 64;
[ObservableProperty] public partial double JumpRange { get; set; } = 20;
[ObservableProperty] public partial double MaxDistance { get; set; } = 60;
[ObservableProperty] public partial double Radius { get; set; } = 500;
[ObservableProperty] public partial double MaxLegs { get; set; } = 4;
[ObservableProperty] public partial double MaxAgeHours { get; set; } = 6;
[ObservableProperty] public partial double MaxArrival { get; set; } = 10000;
[ObservableProperty] public partial double JumpMinutes { get; set; } = 1;
[ObservableProperty] public partial double DockMinutes { get; set; } = 4;
[ObservableProperty] public partial double CostPerJump { get; set; } = 100;
[ObservableProperty] public partial double CostPerMinute { get; set; }
[ObservableProperty] public partial bool Loop { get; set; } = true;
[ObservableProperty] public partial bool IncludePlanetary { get; set; }
[ObservableProperty] public partial bool IncludeCarriers { get; set; }
[ObservableProperty] public partial bool AllowUnknown { get; set; }
[ObservableProperty] public partial int RiskIndex { get; set; } = 1;
[ObservableProperty] public partial int ObjectiveIndex { get; set; }
[ObservableProperty] public partial int PadIndex { get; set; } = 2;
[ObservableProperty] public partial RoutePlan? Plan { get; set; }
[ObservableProperty] public partial TradeLeg? SelectedLeg { get; set; }
[ObservableProperty] public partial string PlanProfit { get; set; } = "—";
[ObservableProperty] public partial string PlanHourly { get; set; } = "—";
[ObservableProperty] public partial string PlanDuration { get; set; } = "—";
[ObservableProperty] public partial string PlanInfo { get; set; } = "选择起点和约束,计算你的下一趟航程。";
[ObservableProperty] public partial string PlanWarnings { get; set; } = "路线估计基于已知行情,出发前需确认游戏中的报价和航线。";
[ObservableProperty] public partial int ExecutionModeIndex { get; set; }
[ObservableProperty] public partial string ExecutionHint { get; set; } = "模拟演练只展示步骤,不向游戏发送输入。";
[ObservableProperty] public partial string RunStatus { get; set; } = "等待路线";
[ObservableProperty] public partial string RunMessage { get; set; } = "输入投入资金并规划路线,选择方案后开始执行。";
[ObservableProperty] public partial string ActualProfit { get; set; } = "0 Cr";
[ObservableProperty] public partial string ProfitLabel { get; set; } = "已确认净利润";
[ObservableProperty] public partial string ProfitDetails { get; set; } = "按实际成交和停靠服务回执更新。";
[ObservableProperty] public partial double RunPercent { get; set; }
[ObservableProperty] public partial string NextDestination { get; set; } = "—";
[ObservableProperty] public partial string JournalDirectory { get; set; } = "";
[ObservableProperty] public partial string EddnEndpoint { get; set; } = "";
[ObservableProperty] public partial bool EddnOnStartup { get; set; }
[ObservableProperty] public partial string ImportSystemName { get; set; } = "";
[ObservableProperty] public partial bool ImportNearby { get; set; } = true;
[ObservableProperty] public partial string ImportStatus { get; set; } = "可按星系补全,或导入 Spansh JSON / GZip 快照。";
[ObservableProperty] public partial string EnvironmentText { get; set; } = "正在自动读取本机舰船与游戏配置";
public string AcceptanceMode => "数据只读接入 · 会话操作由主页按钮或已启用的快捷键触发";
public string DataDirectory => runtime.DataDirectory;
internal bool OfflinePreview{get;}
public ToolboxViewModel(ToolboxRuntime runtime, DispatcherQueue dispatcher,bool offlinePreview=false)
{
galaxyLifetimeToken=lifetime.Token;
OfflinePreview=offlinePreview;
this.runtime = runtime; this.dispatcher = dispatcher;
systemNavigator=runtime.FlightAutomation.Local;systemNavigator.Progress+=OnSystemFlightProgress;
sessions = new(new WindowsGameSessionDriver(() => runtime.Player, () => PluginGameExecutable,
() => PluginRelogReady, ReportRelogProgress, EnsureNativeFlightProtectionAsync), () => RunActive||NavigationActive||SystemFlightActive||NavigationRouteBusy);
sessions.Progress += p => dispatcher.TryEnqueue(() => { if (p.Message.Length > 0) { SessionMessage = p.Message; runtime.Log("会话", p.Message); } });
JournalDirectory = runtime.Settings.JournalDirectory; EddnEndpoint = runtime.Settings.EddnEndpoint;
EddnOnStartup = runtime.Settings.ConnectEddnOnStart;
HotkeysEnabled = runtime.Settings.Hotkeys.Enabled; RelogHotkey = runtime.Settings.Hotkeys.Relog; RestartHotkey = runtime.Settings.Hotkeys.Restart;
InitializePluginSettings();
InitializeTranslationSettings();
InitializeProfile();
InitializeNavigation();
InitializeMarketCacheSettings();
NavigationRouteOptions=runtime.Settings.NavigationRouteOptions;
var r = runtime.Settings.Planner;
Reserve = (double)r.ReserveCredits; Hold = r.CargoCapacity; JumpRange = r.LadenJumpRangeLy;
MaxDistance = r.MaxLegDistanceLy; Radius = r.SearchRadiusLy; MaxLegs = r.MaxLegs; MaxAgeHours = r.MaxMarketAgeMinutes / 60.0;
MaxArrival = r.MaxArrivalDistanceLs; Loop = r.Loop; RiskIndex = (int)r.Risk; ObjectiveIndex = (int)r.Objective;
PadIndex = Math.Max(0, (int)r.RequiredPad - 1); IncludePlanetary = r.IncludePlanetary; IncludeCarriers = r.IncludeCarriers; AllowUnknown = r.AllowUnknownMetadata;
JumpMinutes = r.MinutesPerJump; DockMinutes = r.DockingMinutes; CostPerJump = (double)r.CostPerJump; CostPerMinute = (double)r.CostPerMinute;
InitializeTradePlanningOptions(r);
InitializeTradeRegionScope();
runtime.DataChanged += OnDataChanged;
runtime.RunChanged += OnRunChanged;
runtime.LiveJournalEvent+=OnShieldJournal;
timer = dispatcher.CreateTimer(); timer.Interval = TimeSpan.FromMilliseconds(200); timer.IsRepeating=false;
timer.Tick += async (_, _) =>
{
if(!initialized || lifetime.IsCancellationRequested)return;
if(refreshing){timer.Start();return;}
var changes=(RuntimeChange)Interlocked.Exchange(ref dirty,0);
if(changes!=RuntimeChange.None)await RefreshDataAsync(changes);
if(Volatile.Read(ref dirty)!=0)timer.Start();
};
}
private void OnRuntimeChanged() => OnDataChanged(RuntimeChange.All);
private void OnDataChanged(RuntimeChange changes)
{
if(lifetime.IsCancellationRequested)return;
Interlocked.Or(ref dirty,(int)changes);
dispatcher.TryEnqueue(()=>{if(initialized&&!timer.IsRunning&&!lifetime.IsCancellationRequested)timer.Start();});
}
private static IReadOnlyList<T> StableSequence<T>(IReadOnlyList<T> current,IEnumerable<T> next)
{ var values=next.ToArray();return current.SequenceEqual(values)?current:values; }
private void OnRunChanged(RunProgress progress) => dispatcher.TryEnqueue(() =>
{
if (automaticTradeStop is not null) return;
lastRun = progress;
RunActive = progress.State is RunState.Running or RunState.Paused;
RunStatus = progress.State switch { RunState.Running => "执行中", RunState.Paused => "已暂停", RunState.Completed => "已完成", RunState.Stopped => "已停止", RunState.Failed => "需要处理", _ => "等待路线" };
RunMessage = progress.Message;
PauseRunCommand.NotifyCanExecuteChanged();ResumeRunCommand.NotifyCanExecuteChanged();
ActualProfit = $"{progress.ActualProfit:N0} Cr";
TradeConfirmedProfit=progress.IsSimulation?"0 Cr":ActualProfit;
if(!progress.IsSimulation&&progress.State==RunState.Completed&&!plannedRoundCounted){CompletedTradeRounds++;plannedRoundCounted=true;}
ProfitLabel = progress.IsSimulation ? "模拟预计净收益" : "已确认净利润";
var costs = progress.Accounting;
ProfitDetails = progress.IsSimulation ? "规划估计值,未发生实际交易。"
: $"燃油 {costs.Fuel:N0} · 维修 {costs.Repairs:N0} · 补给 {costs.Rearm:N0} · 其他支出 {costs.OtherExpenses:N0} Cr\n在途货物成本 {costs.CargoInvestment:N0} Cr · 现金变动 {costs.CashChange:+#,0;-#,0;0} Cr";
RunPercent = progress.State == RunState.Completed ? 100 : progress.TotalLegs == 0 ? 0 : (progress.Leg - 1d) / progress.TotalLegs * 100;
NextDestination = Plan?.Legs.ElementAtOrDefault(Math.Max(0, progress.Leg - 1))?.To.DisplayName ?? "—";
RefreshTradePlanCommands(); CalculateCommand.NotifyCanExecuteChanged();
});
public async Task InitializeAsync()
{
initialized = true;
Interlocked.Exchange(ref dirty,0);
await RefreshAsync();
await LoadGalaxyCacheAsync();
// If the saved destination is already reached, present the route the
// player has subsequently plotted in game. This never replots the game.
if(CurrentPlayer.SystemName.Equals(NavigationDestination.Trim(),StringComparison.OrdinalIgnoreCase)
&&CurrentPlayer.Navigation.Route.LastOrDefault() is {} plotted&&plotted.SystemAddress!=CurrentPlayer.Navigation.CurrentSystemAddress)
NavigationDestination=plotted.Name;
RefreshNavigation(CurrentPlayer);
await RefreshUpgradeListAsync();
UsePlayerValues();
if(!OfflinePreview)
{
await ReadEnvironmentAsync();
await ReadPluginStatusAsync();
await ReadTranslationAsync();
await RefreshWalletAsync();
await InitializeAutomaticTradeAsync();
}
if(runtime.Settings.ShieldZeroRestartEnabled&&sessionAutomationAllowed)ShieldZeroRestartEnabled=true;
StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
}
partial void OnDemoModeChanged(bool value)
{
if (revertingDemo) return;
if (RunActive || SessionBusy) { ShowError("请先停止执行或会话操作,再切换数据模式。"); revertingDemo = true; DemoMode = !value; revertingDemo = false; return; }
SetTradeCandidates([]); SelectedStart = null; marketPage = 0;
UsePlayerValues(); OnRuntimeChanged();
StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
}
partial void OnMarketSearchChanged(string value) { CancelMarketRead();marketPage = 0; Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
partial void OnOnlyCurrentStationChanged(bool value) { CancelMarketRead();marketPage = 0; Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
partial void OnMarketSortIndexChanged(int value) { CancelMarketRead();marketPage = 0; Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
partial void OnInventorySearchChanged(string value) => OnDataChanged(RuntimeChange.Player);
partial void OnInventoryTabChanged(int value) => OnDataChanged(RuntimeChange.Player);
partial void OnSelectedShipChanged(ShipLiveRow? value)
{
FittedModules.Apply(value?.Modules ?? []);
SelectedShipSummary = value == null ? "没有飞船装配记录。" : $"{value.DisplayName} · {value.Ident}\n{value.Location} · {value.ConfigurationStatus}\n货舱 {value.CargoCapacity?.ToString() ?? "—"} t · 日志最大跳距 {value.MaxJumpRange?.ToString("F2") ?? "—"} ly · 重购 {value.Rebuy?.ToString("N0") ?? "—"} Cr\n最大跳距不是满载跳距,规划时仍需按载重校准。";
}
partial void OnPlanChanged(RoutePlan? value)
{
NotifyTradePlanningSummary();
RefreshTradePlanCommands();
if (RunActive) return;
RunStatus = value?.Legs.Count > 0 ? "待执行" : "等待路线";
RunMessage = value?.Legs.Count > 0 ? TradeHasExistingCargo?"方案已选定;请先处理现有货物,再开始自动执行。":"已选择路线,点击开始执行后启动。" : "输入投入资金并规划路线,再选择方案开始执行。";
NextDestination = value?.Legs.FirstOrDefault()?.To.DisplayName ?? "—";
}
partial void OnExecutionModeIndexChanged(int value) => ExecutionHint = value switch
{
0 => "模拟演练只展示步骤,不向游戏发送输入。",
1 => "你在游戏中操作;工具按 Journal 自动确认买入、到站、卖出并推进路线。",
_ => "自动执行由工具箱完成采购、航行、出售及站内维护,并核对实际回执。"
};
public void ShowError(string message) => dispatcher.TryEnqueue(() => { ErrorMessage = message; HasError = true; });
private const string MarketCacheFailureMessage="本地商品缓存恢复失败,行情暂不可用。飞船状态与导航继续更新,原始缓存和诊断记录已保留。";
[RelayCommand] private Task RefreshAsync() => RefreshDataAsync(RuntimeChange.All);
private async Task RefreshDataAsync(RuntimeChange changes)
{
if(!initialized)return;
if(refreshing){OnDataChanged(changes);return;}
refreshing=true;
try
{
var demo=DemoMode;var p=CurrentPlayer;
if(OnlyCurrentStation&&lastMarketFilterId!=p.MarketId)changes|=RuntimeChange.Markets;
if((changes&RuntimeChange.Markets)!=0)
{
try
{
var search=MarketSearch;var current=OnlyCurrentStation;var page=marketPage;var sort=(MarketSort)MarketSortIndex;var request=Volatile.Read(ref marketRequest);
var searchToken=BeginMarketRead();
var data=await Task.Run(()=>
{
if(demo)
{
var book=DemoData.CreateBook();
var rows=book.Quotes.Select(q=>new MarketRow(book.Stations.First(s=>s.MarketId==q.MarketId),q)).Where(x=>(!current||x.Station.MarketId==p.MarketId)&&(x.Name.Contains(search,StringComparison.OrdinalIgnoreCase)||x.Location.Contains(search,StringComparison.OrdinalIgnoreCase))).Skip(page*150).Take(150).ToArray();
return(Rows:(IReadOnlyList<MarketRow>)rows,Stations:book.Stations,Count:(Stations:(long)book.Stations.Count,Quotes:(long)book.Quotes.Count,Latest:(DateTimeOffset?)book.Quotes.Max(q=>q.CollectedAt)));
}
var rowsLive=current&&p.MarketId is null?[]:runtime.Markets.Search(search,current?p.MarketId:null,page*150,sort:sort,token:searchToken);
searchToken.ThrowIfCancellationRequested();
var stations=runtime.Markets.GetStations();
if(p.MarketId is {} id&&!stations.Any(s=>s.MarketId==id)&&runtime.Markets.GetStation(id) is {} origin)stations=new[]{origin}.Concat(stations).ToArray();
return(Rows:rowsLive,Stations:stations,Count:runtime.Markets.Statistics());
},lifetime.Token);
if(demo!=DemoMode){OnRuntimeChanged();return;}
if(request!=Volatile.Read(ref marketRequest)){OnDataChanged(changes);return;}
lastMarketFilterId=p.MarketId;
MarketRows.Apply(data.Rows,MarketSortIndex!=0);
MarketPageText=$"第 {marketPage+1} 页 · 本页 {MarketRows.Count} 条";
var oldStart=SelectedStart?.MarketId;var oldDestination=AutomaticDestination?.MarketId;
var selectedStations=new[]{SelectedStart?.Record,AutomaticDestination?.Record,automaticTradeParameters?.Source,automaticTradeParameters?.Destination}.OfType<Station>();
Stations.Apply(data.Stations.Concat(selectedStations));
SelectedStart=Stations.FirstOrDefault(s=>s.MarketId==(oldStart??p.MarketId));
AutomaticDestination=Stations.FirstOrDefault(s=>s.MarketId==oldDestination);
NotifyTradePlanningSummary();
CoverageText=$"{data.Count.Stations:N0} 个已知站点";QuoteCountText=data.Count.Quotes.ToString("N0");
LatestText=data.Count.Latest is {} at?at.ToLocalTime().ToString("MM-dd HH:mm:ss"):"尚无行情";
RefreshMarketPresentation();
if(ErrorMessage==MarketCacheFailureMessage){ErrorMessage="";HasError=false;}
}
catch(OperationCanceledException)when(!lifetime.IsCancellationRequested)
{OnDataChanged(RuntimeChange.Markets);}
catch(TimeoutException)
{MarketQueryStatus="查询超时,仍显示上次有效结果。可限定当前站点或输入完整商品名后重试。";}
catch(Microsoft.Data.Sqlite.SqliteException error)when(error.SqliteErrorCode is 11 or 26)
{
runtime.Log("商品缓存",error.ToString());
try
{
await Task.Run(()=>runtime.Markets.RecoverAsync(lifetime.Token),lifetime.Token);
MarketPageText="商品缓存已恢复,正在重新读取";
OnDataChanged(RuntimeChange.Markets);
}
catch(Exception recoveryError)when(recoveryError is not OperationCanceledException)
{
MarketPageText="商品缓存恢复失败";
runtime.Log("商品缓存恢复",recoveryError.ToString());
ShowError(MarketCacheFailureMessage);
}
}
}
if((changes&RuntimeChange.Player)!=0)
{
RefreshTradePlanningPlayer(p);
OnPropertyChanged(nameof(AutomaticShipSummary));
RefreshRelogAvailability();
Commander=p.Commander;RefreshProfileName();Ship=string.IsNullOrEmpty(p.Ship)?"舰船数据未知":Core.Localization.ChineseNames.Ship(p.Ship);
RefreshProfileData(p);
CreditsText=p.Credits?.ToString("N0")??"—";CargoText=$"{p.CargoCount:N0} / {p.CargoCapacity:N0} t";
MaterialTotalText=p.Materials.Sum(m=>m.Count).ToString("N0");MaterialKindsText=$"{p.Materials.Count} 种已持有材料";
PlayerSnapshotText=$"本机状态 · {p.UpdatedAt.ToLocalTime():MM-dd HH:mm:ss}";
BalanceNote=p.CreditsReliable?"Journal 余额 · 交易后自动更新":"余额可能不完整 · 重新登录游戏可校准";
SystemName=string.IsNullOrWhiteSpace(p.SystemName)?"等待位置数据":p.SystemName;
StationName=string.IsNullOrEmpty(p.StationName)?"深空 / 航行中":p.StationName;
DockingText=demo?"内部界面测试":!p.InGame?"游戏会话已结束":p.Docked?"已停靠":"航行中";
CargoNote=$"可用货舱 {p.FreeCargo:N0} t · 任务货物保留";
var inventory=InventoryTab switch{1=>p.Materials,2=>p.Locker,3=>p.Backpack,_=>p.Inventory};
InventoryRows.Apply(inventory.Where(i=>i.Name.Contains(InventorySearch,StringComparison.OrdinalIgnoreCase)||i.Key.Contains(InventorySearch,StringComparison.OrdinalIgnoreCase)).OrderBy(i=>i.Name));
RefreshInventoryPresentation();
var selectedId=SelectedShip?.ShipId??p.CurrentShipId;
Ships.Apply(p.Ships);StoredModules.Apply(p.StoredModules);
SelectedShip=Ships.FirstOrDefault(x=>x.ShipId==selectedId)??Ships.FirstOrDefault();
OnSelectedShipChanged(SelectedShip);
FleetSummary=$"{Ships.Count} 艘已知飞船 · {Ships.Count(s=>s.ConfigurationAt!=null)} 艘有历史装配 · {StoredModules.Count} 个寄存模块";
var activeShip=p.Ships.FirstOrDefault(x=>x.ShipId==p.CurrentShipId);
CurrentShipSummary=activeShip is null?"当前飞船装配未读取":$"当前:{activeShip.DisplayName} · #{activeShip.ShipId}\n{activeShip.ConfigurationStatus}";
CockpitSummary=p.Status is {} cockpit?cockpit.FuelText+"\n"+cockpit.PipsText+$"\nFlags {cockpit.Flags} · Flags2 {cockpit.Flags2}":"尚未读取座舱状态";
RefreshEngineering(p);
await RefreshUpgradeListAsync();
RefreshNavigation(p);
if(ImportSystemName.Length==0&&p.SystemName.Length>0)ImportSystemName=p.SystemName;
}
if((changes&(RuntimeChange.Player|RuntimeChange.Markets))!=0)
{
ReadChecks.Apply(ReadAcceptance.Build(p,demo?null:runtime.Journal.LocalMarket));
ReadSummary=$"已解析 {runtime.Journal.EventsRead:N0} 条 Journal 事件 · 检测到变化时更新\n{runtime.Journal.DirectoryPath}\n每一项时间均为游戏最后写出时间,不代表全部数据当前有效。";
}
if((changes&(RuntimeChange.Connection|RuntimeChange.Markets))!=0)
{
EddnCountText=(runtime.Eddn?.MessagesReceived??0).ToString("N0");StatusText=runtime.Eddn?.Status??"EDDN 未连接 · 本地数据可用";
SourceNote="本地 Market + EDDN 增量行情 + Spansh 快照";
}
if((changes&RuntimeChange.Activity)!=0)Activity.Apply(runtime.Activity);
if((changes&RuntimeChange.GameEvents)!=0&&GameLogPageVisible&&gameLogPage==0)await RefreshGameEventsAsync();
if((changes&(RuntimeChange.Player|RuntimeChange.Markets|RuntimeChange.Connection))!=0)RefreshDashboard(p);
if((changes&(RuntimeChange.Player|RuntimeChange.Markets))!=0)await RefreshGalaxyAsync(p,(changes&RuntimeChange.Markets)!=0);
}
catch(OperationCanceledException)when(lifetime.IsCancellationRequested){}
catch(Exception e){runtime.Log("界面数据刷新",e.ToString());ShowError(e.Message);}
finally{refreshing=false;}
}
[RelayCommand]
private void UsePlayerValues()
{
RefreshTradePlanningPlayer(CurrentPlayer,force:true);
}
partial void OnSessionBusyChanged(bool value)
{
StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();
RefreshTradePlanCommands(); CalculateCommand.NotifyCanExecuteChanged();
StartNavigationCommand.NotifyCanExecuteChanged();
StartRemoteJourneyCommand.NotifyCanExecuteChanged();
UpdateSystemControlAvailability();
}
partial void OnRunActiveChanged(bool value) { RefreshTradeExecutionTimer(value);RefreshTradePlanCommands();NotifyTradePlanningSummary(); PauseRunCommand.NotifyCanExecuteChanged();ResumeRunCommand.NotifyCanExecuteChanged();StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();StartNavigationCommand.NotifyCanExecuteChanged();UpdateSystemControlAvailability(); }
private bool CanManageSession() => initialized && !DemoMode && !RunActive && !SessionBusy&&!NavigationActive&&!SystemFlightActive&&!NavigationRouteBusy;
private bool CanRelog() => CanManageSession() && sessions.CanRelog;
[RelayCommand(CanExecute = nameof(CanManageSession))] private Task StartGameAsync() => RunSessionAsync(SessionOperation.Start);
[RelayCommand(CanExecute = nameof(CanRelog))] private Task RelogGameAsync() => RunSessionAsync(SessionOperation.Relog);
[RelayCommand(CanExecute = nameof(CanManageSession))] private Task RestartGameAsync() => RunSessionAsync(SessionOperation.Restart);
[RelayCommand] private void CancelSession() => sessionStop?.Cancel();
public void InitializeHotkeys(bool activate)
{
sessionAutomationAllowed=activate;
hotkeys = new(WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow), operation => dispatcher.TryEnqueue(() =>
{
if (!CanManageSession()) { SessionMessage = "当前状态不能执行会话操作。"; return; }
if (operation == SessionOperation.Relog && !sessions.CanRelog) { SessionMessage = RelogAvailabilityText; return; }
_ = RunSessionAsync(operation);
}));
if (!activate) { HotkeyStatus = "隐藏界面检查:未注册任何用户快捷键。"; return; }
try { hotkeys.Apply(runtime.Settings.Hotkeys); UpdateHotkeyStatus(); }
catch (Exception e) { HotkeyStatus = e.Message; ShowError(e.Message); }
}
private void UpdateHotkeyStatus()
{
var s = hotkeys?.Settings ?? new();
HotkeyStatus = !s.Enabled ? "全局快捷键已关闭。" : $"当前生效:重进 {(s.Relog.Length > 0 ? s.Relog : "未绑定")} · 大退 {(s.Restart.Length > 0 ? s.Restart : "未绑定")}。";
}
[RelayCommand] private async Task SaveHotkeysAsync()
{
if (hotkeys is null) return;
var previous = hotkeys.Settings;
try
{
hotkeys.Apply(new(HotkeysEnabled, RelogHotkey, RestartHotkey));
await runtime.SaveSettingsAsync(runtime.Settings with { Hotkeys = hotkeys.Settings });
RelogHotkey = hotkeys.Settings.Relog; RestartHotkey = hotkeys.Settings.Restart; UpdateHotkeyStatus();
}
catch (Exception e)
{
try { hotkeys.Apply(previous); } catch (Exception restore) { HotkeyStatus = "快捷键恢复失败:" + restore.Message; }
HotkeyStatus = "未应用本次修改:" + e.Message; ShowError(e.Message);
}
}
private async Task RunSessionAsync(SessionOperation operation)
{
if (!CanManageSession()) return;
ActiveSessionOperation=operation;SessionBusy = true; HasError = false;
using var operationStop = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
sessionStop = operationStop;
try { await sessions.RunAsync(operation, operationStop.Token); }
catch (OperationCanceledException) { SessionMessage = operation == SessionOperation.Relog ? "已取消后续重进操作,已提交给游戏的菜单操作仍会完成。" : "会话操作已取消。"; }
catch (Exception e) { ShowError(e.Message); }
finally { sessionStop = null; SessionBusy = false; ActiveSessionOperation=null; }
}
private bool CanCalculate() => !RunActive && !SessionBusy && !IsBusy&&!TradeRegionBusy&&TradeShipPadKnown;
private bool CanImportData() => !IsBusy;
partial void OnIsBusyChanged(bool value)
{
OnPropertyChanged(nameof(TradeSelectionEnabled));
RefreshTradePlanCommands();
CalculateCommand.NotifyCanExecuteChanged();ImportSystemCommand.NotifyCanExecuteChanged();
ImportSpanshCommand.NotifyCanExecuteChanged();ImportCaptureCommand.NotifyCanExecuteChanged();
}
[RelayCommand(IncludeCancelCommand = true, CanExecute = nameof(CanCalculate))]
private async Task CalculateAsync(CancellationToken token)
{
IsBusy = true; HasError = false;
var progress=BeginTradePlanningProgress(token);
try
{
RefreshTradePlanningPlayer(CurrentPlayer);
if(!double.IsFinite(Radius))Radius=runtime.Settings.MarketCacheRadiusLy;
await PrepareTradeRegionForPlanningAsync(token);
var request = BuildRequest();
var inputVersion=Volatile.Read(ref tradePlanningInputVersion);
var demo = DemoMode;
SetTradeCandidates([]);TradeEmptyTitle="正在比较路线";TradeEmptyDescription="正在核对范围内的行情、供需与飞船约束。";
TradePlanningStatus="正在比较可行路线与预计小时收益…";
IReadOnlyList<CommunityGoalInfo> goals=[];
if(request.CommunityGoalSupplyOnly)
{
ApplyTradePlanningProgress(new("核对 CG 供货条件",0,null,"只读确认有效活动、交付站与接收商品;未确认时不推荐普通路线。"));
goals=await ReadTradeCommunityGoalsForPlanningAsync(demo,token);
}
var book=demo?DemoData.CreateBook():await Task.Run(()=>runtime.Markets.LoadPlanningRegion(request,token:token,progress:progress),token);
var result = await Task.Run(() => new TradeRoutePlanner().CalculateCandidatesWithDiagnostics(book, request,3,token,progress,goals), token);
Interlocked.Increment(ref tradePlanningOperation);
if(!request.CommunityGoalSupplyOnly&&result.Candidates.Count>0)
{
ApplyTradePlanningProgress(new("核对CG活动目录",0,null,"最多等待三秒;活动未知不影响常规市场利润的比较。"));
goals=await ReadTradeCommunityGoalsForPlanningAsync(demo,token);
}
else if(request.CommunityGoalSupplyOnly&&result.Candidates.Count>0
&&(tradeCommunityGoalVerifiedAt is not {} readAt||DateTimeOffset.UtcNow-readAt>TimeSpan.FromMinutes(2)))
goals=await ReadTradeCommunityGoalsForPlanningAsync(demo,token);
token.ThrowIfCancellationRequested();
if (demo != DemoMode) return;
if(inputVersion!=Volatile.Read(ref tradePlanningInputVersion))
{SetTradePlanningIssue("资料已更新,请重新规划","飞船、货舱、位置或规划参数已在计算期间变化,请按当前数据重新规划。");return;}
SetTradeCandidates(result.Candidates,result.Diagnostics,goals);
runtime.Log("规划",JsonSerializer.Serialize(result.Diagnostics,ToolboxSettings.JsonOptions));
if (!demo) await runtime.SaveSettingsAsync(runtime.Settings with { Planner = request });
}
catch (OperationCanceledException) { SetTradePlanningIssue("规划已取消","本次规划已取消,点击规划路线可重新比较。"); }
catch (Exception e) { SetTradePlanningIssue("暂时无法生成方案",e.Message);ShowError(e.Message); }
finally { EndTradePlanningProgress();IsBusy = false; }
}
private PlannerRequest BuildRequest()
{
var player=CurrentPlayer;
var defaults=runtime.Settings.Planner;
static double Optional(double value,double fallback)=>double.IsFinite(value)?value:fallback;
var cash=Optional(Cash,(double)(player.Credits??0));var reserve=Optional(Reserve,(double)player.Rebuy);
var investment=Optional(TradeInvestment,Math.Max(0,cash-reserve));
if(investment<=0||investment>Math.Max(0,cash-reserve))throw new ArgumentException("投入资金须大于零,且不能超过留出运营与保险资金后的可用余额。");
var shipPad=ShipPadPolicy.RequiredPad(player.Ship);
if(shipPad==LandingPad.Unknown)throw new ArgumentException("当前飞船尺寸尚未识别,请等待真实飞船数据后再规划。");
var request = new PlannerRequest
{
StartMarketId = player.MarketId??0, Credits = (decimal)cash, ReserveCredits = Math.Max((decimal)reserve,(decimal)cash-(decimal)investment),
InvestmentCredits=investmentFollowsBalance?null:(decimal)investment,
CargoCapacity = checked((int)Optional(Hold,player.CargoCapacity)),OccupiedCargo=player.CargoCount,LadenJumpRangeLy = Optional(JumpRange,player.MaxJumpRange??defaults.LadenJumpRangeLy),JumpRangeIsEstimate=true,
CurrentPosition=player.Position,CurrentSystemName=player.SystemName,CurrentMarketId=player.MarketId,MaxLegDistanceLy = Optional(MaxDistance,defaults.MaxLegDistanceLy),
SearchRadiusLy = Optional(Radius,runtime.Settings.MarketCacheRadiusLy),SearchCenterPosition=EffectiveTradeRegionCenter,SearchCenterName=TradeRegionCenterText.Trim().Length==0?player.SystemName:resolvedTradeRegionName,
MaxLegs = checked((int)Optional(MaxLegs,defaults.MaxLegs)), Loop = Loop,CommunityGoalSupplyOnly=TradePlanningModeIndex==1,
CommunityGoalCommodityFilter=TradePlanningModeIndex==1?TradeSearchConstraints.ParseCommodityFilter(TradeCommoditySearchText):[],
SourceMarketIdFilter=tradeSourceSearchMarketId,DestinationMarketIdFilter=tradeDestinationSearchMarketId,
SourceSystemFilter=TradeSourceSystemText?.Trim()??"",SourceStationFilter=TradeSourceSearchStation?.Record.Name??TradeSourceSearchText?.Trim()??"",
DestinationSystemFilter=TradeDestinationSystemText?.Trim()??"",DestinationStationFilter=TradeDestinationSearchStation?.Record.Name??TradeDestinationSearchText?.Trim()??"",
Risk = RiskIndex is >=0 and <=2?(RiskTolerance)RiskIndex:defaults.Risk, Objective = ObjectiveIndex is >=0 and <=2?(RouteObjective)ObjectiveIndex:defaults.Objective,
ActualShipType=player.Ship,ShipRequiredPad=shipPad,AllowedSourceKinds=SelectedTradeSourceKinds==0?StationSourceKinds.AllKnown:SelectedTradeSourceKinds,
RequiredPad = (LandingPad)Math.Max((int)shipPad,PadIndex is >=0 and <=2?PadIndex+1:(int)shipPad), IncludePlanetary = true,
IncludeCarriers = true, AllowUnknownMetadata = AllowUnknown,
MaxMarketAgeMinutes = checked((int)(Optional(MaxAgeHours,defaults.MaxMarketAgeMinutes/60d) * 60)), MaxArrivalDistanceLs = Optional(MaxArrival,defaults.MaxArrivalDistanceLs),
MinutesPerJump = Optional(JumpMinutes,defaults.MinutesPerJump), DockingMinutes = Optional(DockMinutes,defaults.DockingMinutes), CostPerJump = (decimal)Optional(CostPerJump,(double)defaults.CostPerJump), CostPerMinute = (decimal)Optional(CostPerMinute,(double)defaults.CostPerMinute)
};
request.Validate(); return request;
}
private bool CanStartPlan() => Plan?.Legs.Count > 0 && !RunActive && !SessionBusy&&!NavigationActive;
[RelayCommand(CanExecute = nameof(CanStartPlan))]
private async Task StartPlanAsync(CancellationToken cancellationToken=default)
{
using var stop=CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token,cancellationToken);var token=stop.Token;
try
{
if (Plan == null) return;
if (DemoMode && ExecutionModeIndex != 0) throw new InvalidOperationException("演示沙盘仅支持模拟演练。");
if (ExecutionModeIndex is < 0 or > 2) throw new InvalidOperationException("请选择有效的执行方式。");
if(ExecutionModeIndex==2&&(!CurrentPlayer.Docked||CurrentPlayer.MarketId!=Plan.Legs[0].From.MarketId))
throw new InvalidOperationException("请先停靠 "+Plan.Legs[0].From.DisplayName+" 再执行此完整路线;前往起点的耗时目前只计入估算。");
BeginPlannedTradeMetrics();
var nativeDriver=ExecutionModeIndex==2?await CreateNativeTradeDriverAsync(Plan,token):null;
token.ThrowIfCancellationRequested();
AppShellPage.Current?.Navigate(typeof(PlannerPage));
RunActive = true;
await runtime.Runner.RunAsync(Plan, (ExecutionMode)ExecutionModeIndex, nativeDriver, token);
}
catch(OperationCanceledException){runtime.Log("执行","执行已取消。");}
catch (Exception e) { ShowError(e.Message); }
finally { RunActive = false; RefreshTradePlanCommands(); CalculateCommand.NotifyCanExecuteChanged(); }
}
private bool CanPauseRun()=>automaticTradeStop is null&&runtime.Runner.State==RunState.Running;
private bool CanResumeRun()=>automaticTradeStop is null&&runtime.Runner.State==RunState.Paused;
[RelayCommand(CanExecute=nameof(CanPauseRun))] private void PauseRun() => runtime.Runner.Pause();
[RelayCommand(CanExecute=nameof(CanResumeRun))] private void ResumeRun() { try { runtime.Runner.Resume(); } catch (Exception e) { ShowError(e.Message); } }
[RelayCommand] private void StopRun()
{
if (automaticTradeStop is { } stop)
{
RunStatus = "正在取消";
RunMessage = "正在停止跑商,核对已提交请求并释放控制。";
stop.Cancel();
return;
}
runtime.Runner.Stop();
}
[RelayCommand] private void PreviousMarketPage() { CancelMarketRead();marketPage = Math.Max(0, marketPage - 1); Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
[RelayCommand] private void NextMarketPage() { CancelMarketRead();if (MarketRows.Count == 150) marketPage++; Interlocked.Increment(ref marketRequest); OnDataChanged(RuntimeChange.Markets); }
[RelayCommand] private void CopyDestination()
{
var leg = Plan?.Legs.ElementAtOrDefault(Math.Max(0, (lastRun?.Leg ?? 1) - 1));
if (leg == null) return;
var data = new DataPackage(); data.SetText(leg.To.SystemName); Clipboard.SetContent(data);
runtime.Log("导航", $"已复制目的星系:{leg.To.SystemName}");
}
[RelayCommand] private async Task ExportPlanAsync()
{
try
{
if (Plan == null) throw new InvalidOperationException("请先计算路线。");
var picker = new FileSavePicker { SuggestedFileName = $"ED-Trade-{DateTime.Now:yyyyMMdd-HHmm}" };
picker.FileTypeChoices.Add("路线 JSON", new[] { ".json" }); InitializePicker(picker);
if (await picker.PickSaveFileAsync() is { } file) await File.WriteAllTextAsync(file.Path, JsonSerializer.Serialize(Plan, ToolboxSettings.JsonOptions));
}
catch (Exception e) { ShowError(e.Message); }
}
[RelayCommand] private async Task ExportReadReportAsync()
{
try
{
if (DemoMode) throw new InvalidOperationException("请切换到实时接入后导出真实数据验收报告。");
var p = runtime.Player;
var cache = runtime.Markets.Statistics();
var report = new { generatedAt = DateTimeOffset.UtcNow, mode = "external-read-only", journalDirectory = runtime.Journal.DirectoryPath,
eventsRead = runtime.Journal.EventsRead, checks = ReadAcceptance.Build(p, runtime.Journal.LocalMarket),
environment = gameEnvironment, player = p with { CommanderId = "" },
localMarket = runtime.Journal.LocalMarket, publicMarketCache = new { cache.Stations, cache.Quotes, cache.Latest },
limits = new[] { "未使用过的远程飞船可能只有清单而没有装配", "历史装配不等于实时配置", "公共行情不覆盖所有站点的即时价格", "本报告不证明零封禁风险" } };
var folder = Path.Combine(runtime.DataDirectory, "reports"); Directory.CreateDirectory(folder);
var path = Path.Combine(folder, $"read-acceptance-{DateTime.Now:yyyyMMdd-HHmmss}.json");
await File.WriteAllTextAsync(path, JsonSerializer.Serialize(report, ToolboxSettings.JsonOptions));
ReadReportPath = path; runtime.Log("验收", "已导出只读数据报告(不含账号认证信息)。");
}
catch (Exception e) { ShowError(e.Message); }
}
[RelayCommand] private async Task SaveSettingsAsync()
{
try
{
if (!Uri.TryCreate(EddnEndpoint, UriKind.Absolute, out var uri) || uri.Scheme != "tcp" || uri.Port <= 0) throw new ArgumentException("请输入有效的 EDDN TCP 地址。");
await runtime.SaveSettingsAsync(runtime.Settings with { JournalDirectory = JournalDirectory.Trim(), EddnEndpoint = EddnEndpoint.Trim(), ConnectEddnOnStart = EddnOnStartup });
OnRuntimeChanged();
}
catch (Exception e) { ShowError(e.Message); }
}
[RelayCommand] private async Task ToggleEddnAsync()
{
try { if (runtime.Eddn == null) runtime.StartEddn(); else await runtime.StopEddnAsync(); }
catch (Exception e) { ShowError(e.Message); }
}
[RelayCommand(IncludeCancelCommand = true,CanExecute=nameof(CanImportData))] private async Task ImportSystemAsync(CancellationToken token)
{
IsBusy = true;
try
{
var progress = new Progress<ImportProgress>(p => ImportStatus = $"{p.Message} · {p.Systems} 星系 / {p.Stations} 站点 / {p.Quotes:N0} 报价");
var result = ImportNearby ? await runtime.Spansh.ImportNearbyAsync(ImportSystemName.Trim(), 20, progress, token) : await runtime.Spansh.ImportSystemAsync(ImportSystemName.Trim(), progress, token);
ImportStatus = $"已处理 {result.Systems} 个星系、{result.Stations} 个站点;更新 {result.Quotes:N0} 条报价。保留源数据时间。";
runtime.Log("导入", ImportStatus); OnRuntimeChanged();
}
catch (OperationCanceledException) { ImportStatus = "导入已取消,已完成的数据保留。"; }
catch (Exception e) { ImportStatus = "导入未完成"; ShowError(e.Message); }
finally { IsBusy = false; }
}
[RelayCommand(CanExecute=nameof(CanImportData))] private Task ImportSpanshAsync(CancellationToken token)=>ImportFileAsync("spansh",token);
[RelayCommand(CanExecute=nameof(CanImportData))] private Task ImportCaptureAsync(CancellationToken token)=>ImportFileAsync("capture",token);
private async Task ImportFileAsync(string kind, CancellationToken token)
{
IsBusy = true;
try
{
var picker = new FileOpenPicker();
foreach (var ext in kind == "capture" ? new[] { ".zip" } : new[] { ".json", ".gz" }) picker.FileTypeFilter.Add(ext);
InitializePicker(picker);
if (await picker.PickSingleFileAsync().AsTask(token) is not { } file) return;
token.ThrowIfCancellationRequested();
var progress = new Progress<ImportProgress>(p => ImportStatus = $"{p.Message} · {p.Stations} 站点 / {p.Quotes:N0} 报价");
var result = kind == "capture" ? await Task.Run(() => CaptureImporter.ImportAsync(file.Path, runtime.Markets, progress, token), token) : await Task.Run(() => runtime.Spansh.ImportFileAsync(file.Path, progress: progress, token: token), token);
ImportStatus = $"完成:{result.Stations} 个站点,{result.Quotes:N0} 条报价。"; runtime.Log("导入", ImportStatus); OnRuntimeChanged();
}
catch (OperationCanceledException) { ImportStatus = "导入已取消,已完成的数据保留。"; }
catch (Exception e) { ShowError(e.Message); }
finally { IsBusy = false; }
}
[RelayCommand] private async Task ChooseJournalAsync()
{
var picker = new FolderPicker(); picker.FileTypeFilter.Add("*"); InitializePicker(picker);
if (await picker.PickSingleFolderAsync() is { } folder) JournalDirectory = folder.Path;
}
[RelayCommand] private async Task ReadEnvironmentAsync(CancellationToken cancellationToken=default)
{
using var stop=CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token,cancellationToken);var token=stop.Token;
try
{
gameEnvironment = await GameEnvironmentReader.ReadAsync(runtime.Player, token: token);
EnvironmentText = gameEnvironment.Summary + "\n" + string.Join("\n", gameEnvironment.Diagnostics);
}
catch(OperationCanceledException){EnvironmentText="环境读取已取消。";}
catch (Exception e) { ShowError(e.Message); }
}
[RelayCommand] private void OpenDataDirectory() => Process.Start(new ProcessStartInfo(runtime.DataDirectory) { UseShellExecute = true });
private static void InitializePicker(object picker) => WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow));
public void Dispose()
{
runtime.FlightAutomation.Progress-=OnFlightAutomationProgress;runtime.FlightAutomation.Stop();
runtime.Navigator.Progress-=OnNavigationProgress;runtime.Navigator.Stop();
systemNavigator.Progress-=OnSystemFlightProgress;systemNavigator.Stop();systemFlightStop?.Cancel();currentSystemTimer?.Stop();currentSystemSource?.Dispose();
if(shipProtection is {} protection)_=protection.DisposeAsync();
hotkeys?.Dispose(); sessionStop?.Cancel();
runtime.LiveJournalEvent-=OnShieldJournal;
walletTimer?.Stop();
tradeExecutionTimer?.Stop();
shipTelemetryTimer?.Stop();
timer.Stop(); runtime.DataChanged -= OnDataChanged; runtime.RunChanged -= OnRunChanged;
lifetime.Cancel(); lifetime.Dispose();
galaxyStreaming.Dispose();galaxyCellStreaming.Dispose();if(nativeGalaxy is not null&&galaxyNativeProgressHandler is not null)nativeGalaxy.RegionProgress-=galaxyNativeProgressHandler;
galaxyHttp.Dispose();
}
}