using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Newtonsoft.Json.Linq;
namespace XFEWarframe.Desktop.Editing;
public sealed record EquipmentTarget(string AccountId, string AccountName, string Category, string Item);
public sealed record EquipmentRevision(string Version, string Permissions, bool Enabled);
public sealed record EquipmentTab(string Key, string Title, IReadOnlyList<EquipmentAction> Actions);
public sealed class EquipmentAction(EditorAction source)
{
public string Key => source.Key;
public string Label => source.Label;
public string Description => source.Description;
public IReadOnlyList<EditorField> Fields { get; } = source.Fields.OfType<JObject>().Select(x => new EditorField(x)).ToArray();
public bool Changed => Fields.Any(x => x.Changed);
}
/// <summary>One detail editor for both desktop roles. Transports supply only actions authorized by the server.</summary>
public sealed class EquipmentEditorModel : ObservableObject
{
private readonly Func<EquipmentTarget, Task<JObject>> read;
private readonly Func<EquipmentTarget, JObject, Task<JObject>> save;
private readonly Func<EquipmentTarget, Task<EquipmentRevision>> revision;
private readonly Func<string, string, Task<bool>> confirm;
private readonly Action<string> notify;
private readonly EditorChangeMonitor changes;
private EquipmentTarget? target;
private string? version, permissionVersion;
private int generation;
private bool busy, conflict;
private string title = UiText.Translate("装备详情"), subtitle = "", summary = "", message = "";
private IReadOnlyList<EquipmentTab> tabs = [];
public string Title { get => title; private set => SetProperty(ref title, value); }
public string Subtitle { get => subtitle; private set => SetProperty(ref subtitle, value); }
public string Summary { get => summary; private set => SetProperty(ref summary, value); }
public string Message { get => message; private set { SetProperty(ref message, value); OnPropertyChanged(nameof(HasMessage)); } }
public bool HasMessage => Message.Length > 0;
public string SelectedTabKey { get; set; } = "basic";
public IReadOnlyList<EquipmentTab> Tabs { get => tabs; private set => SetProperty(ref tabs, value); }
public IEnumerable<EquipmentAction> Actions => Tabs.SelectMany(x => x.Actions);
public bool HasChanges => Actions.Any(x => x.Changed);
public bool HasConflict { get => conflict; private set { SetProperty(ref conflict, value); SaveCommand.NotifyCanExecuteChanged(); } }
public bool IsBusy { get => busy; private set { SetProperty(ref busy, value); OnPropertyChanged(nameof(IsIdle)); SaveCommand.NotifyCanExecuteChanged(); ResetCommand.NotifyCanExecuteChanged(); RefreshCommand.NotifyCanExecuteChanged(); } }
public bool IsIdle => !IsBusy;
public IAsyncRelayCommand<EquipmentAction> SaveCommand { get; }
public IRelayCommand<EquipmentAction> ResetCommand { get; }
public IAsyncRelayCommand RefreshCommand { get; }
public EquipmentEditorModel(Func<EquipmentTarget, Task<JObject>> read,
Func<EquipmentTarget, JObject, Task<JObject>> save,
Func<EquipmentTarget, Task<EquipmentRevision>> revision,
Func<string, string, Task<bool>> confirm, Action<string> notify)
{
this.read = read; this.save = save; this.revision = revision; this.confirm = confirm; this.notify = notify;
changes = new EditorChangeMonitor(CheckChangesAsync);
SaveCommand = new AsyncRelayCommand<EquipmentAction>(SaveAsync, action => !IsBusy && !HasConflict && version is not null && action is not null && Actions.Contains(action));
ResetCommand = new RelayCommand<EquipmentAction>(Reset, _ => !IsBusy);
RefreshCommand = new AsyncRelayCommand(() => RunAsync(async () =>
{
if (target is null || !await CanDiscardAsync()) return;
await ReadAsync(false);
}), () => !IsBusy);
}
public void Attach() => changes.Start();
public void Detach() { changes.Stop(); generation++; }
public Task LoadAsync(EquipmentTarget? next) => RunAsync(async () =>
{
generation++; target = next; version = permissionVersion = null; Tabs = []; HasConflict = false; Message = "";
Title = UiText.Translate("装备详情"); Summary = "";
Subtitle = next is null ? "" : next.AccountName + " · " + InventoryNavigation.Label(next.Category);
if (next is null) { Message = UiText.Translate("请从库存列表选择一件装备。"); return; }
await ReadAsync(false);
});
private async Task ReadAsync(bool preserveDrafts, string? savedKey = null)
{
var selected = target!; var observed = generation; var pageGeneration = changes.Generation;
var data = await read(selected);
var prepared = await Task.Run(() => ((JArray?)data["actions"] ?? []).OfType<JObject>().Select(x =>
new EquipmentAction(new EditorAction(x.Value<string>("key")!, x.Value<string>("label")!, x.Value<string>("description") ?? "", (JArray)x["fields"]!, ""))).ToArray());
if (selected != target || observed != generation || pageGeneration != changes.Generation) throw new OperationCanceledException();
if (data.Value<string>("selectedKey") != selected.Item)
{
Tabs = []; version = null; HasConflict = true; Message = UiText.Translate("此物品已不存在,请返回库存列表。"); return;
}
if (preserveDrafts)
foreach (var action in prepared.Where(x => x.Key != savedKey))
if (Actions.FirstOrDefault(x => x.Key == action.Key) is { } previous)
foreach (var field in action.Fields)
if (previous.Fields.FirstOrDefault(x => x.Key == field.Key) is { Changed: true } draft)
{ field.Value = draft.Value.DeepClone(); field.HasUnresolvedChoice = draft.HasUnresolvedChoice; field.ChoiceQuery = draft.ChoiceQuery; }
version = data.Value<string>("version"); permissionVersion = data.Value<string>("permissionsRevision") ?? permissionVersion;
Title = data.Value<string>("selectedName") ?? Title;
Summary = data.Value<string>("summary") ?? ""; HasConflict = false;
Tabs = prepared.GroupBy(x => Group(x.Key)).OrderBy(x => x.Key.Order)
.Select(x => new EquipmentTab(x.Key.Key, UiText.Translate(x.Key.Title), x.ToArray())).ToArray();
Message = prepared.Length == 0 ? UiText.Translate("管理员未允许编辑此物品。") : "";
SaveCommand.NotifyCanExecuteChanged();
}
private static (int Order, string Key, string Title) Group(string action) => action switch
{
"rename" or "experience" or "max" or "forma" or "mod.rank" or "mature" => (0, "basic", "基础属性"),
"features" => (1, "features", "装备特性"),
var key when key.StartsWith("shards.", StringComparison.Ordinal) => (2, "shards", "源力石"),
"umbra" or "umbra.remove" => (3, "umbra", "Umbra 回响"),
"invigoration" or "invigoration.remove" => (4, "invigoration", "活力注入"),
var key when key.StartsWith("ability.multiple.", StringComparison.Ordinal) => (5, "experimental-abilities", "多技能实验"),
var key when key.StartsWith("ability.", StringComparison.Ordinal) => (5, "abilities", "移植技能"),
"parts" or "valence" => (6, "parts", "部件与元素"),
"remove" => (99, "manage", "物品管理"),
_ => (7, "other", "其他编辑")
};
private Task SaveAsync(EquipmentAction? action) => RunAsync(async () =>
{
if (action is null || target is null || version is null || HasConflict || !Actions.Contains(action)) return;
var values = new JObject(action.Fields.Select(x => new JProperty(x.Key, x.Serialize())));
if ((action.Key is "remove" or "shards.remove" or "umbra.remove" or "invigoration.remove") &&
!await confirm(action.Label, target.AccountName + " · " + Title + "\n" + action.Description)) return;
await save(target, new JObject { ["version"] = version, ["category"] = target.Category, ["item"] = target.Item, ["action"] = action.Key, ["values"] = values });
await ReadAsync(true, action.Key);
notify(UiText.Translate("存档已保存"));
});
private void Reset(EquipmentAction? action)
{
if (action is null || !Actions.Contains(action)) return;
foreach (var field in action.Fields) field.Reset();
OnPropertyChanged(nameof(Tabs));
}
public Task<bool> CanDiscardAsync() => HasChanges
? confirm(UiText.Translate("放弃未保存的修改?"), UiText.Translate("当前装备有未保存的修改。继续后将丢弃这些输入。"))
: Task.FromResult(true);
public async Task CheckChangesAsync()
{
if (!changes.Active || IsBusy || target is null) return;
var observed = version; var selected = target; var pageGeneration = changes.Generation;
try
{
var current = await revision(selected);
if (!changes.Active || IsBusy || target != selected || observed != version || pageGeneration != changes.Generation) return;
if (!current.Enabled) { Revoke(); return; }
if (version is null) { await RunAsync(() => ReadAsync(false)); return; }
if (permissionVersion is not null && permissionVersion != current.Permissions)
{
var previousVersion = version;
var existingConflict = HasConflict;
await RunAsync(() => ReadAsync(true)); // Rebuild from the server's newly filtered action set.
// The save can change after the revision request but before the actual read completes.
// ReadAsync advances the permission version only on success so a failed refresh is retried.
if (HasChanges && (existingConflict || current.Version != previousVersion || version != previousVersion))
{
HasConflict = true;
Message = UiText.Translate("存档已在其他位置更新,输入已保留。请刷新后再保存。");
}
return;
}
permissionVersion = current.Permissions;
if (current.Version == version) return;
if (HasChanges)
{
HasConflict = true;
Message = UiText.Translate("存档已在其他位置更新,输入已保留。请刷新后再保存。");
}
else await RunAsync(() => ReadAsync(false));
}
catch (OperationCanceledException) { }
catch { if (changes.Active && target == selected) Message = UiText.Translate("连接中断,当前输入已保留。"); }
}
public void Revoke(bool forgetTarget = false)
{
generation++; Tabs = []; version = null; HasConflict = true;
if (forgetTarget) target = null;
Message = UiText.Translate("编辑权限已关闭或账号已退出,请返回库存列表。");
}
private async Task RunAsync(Func<Task> operation)
{
if (IsBusy) return;
IsBusy = true; Message = "";
try { await operation(); }
catch (OperationCanceledException) { }
catch (Exception ex) { Message = ex.Message; }
finally { IsBusy = false; }
}
}
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Newtonsoft.Json.Linq;
namespace XFEWarframe.Desktop.Editing;
public sealed record EquipmentTarget(string AccountId, string AccountName, string Category, string Item);
public sealed record EquipmentRevision(string Version, string Permissions, bool Enabled);
public sealed record EquipmentTab(string Key, string Title, IReadOnlyList<EquipmentAction> Actions);
public sealed class EquipmentAction(EditorAction source)
{
public string Key => source.Key;
public string Label => source.Label;
public string Description => source.Description;
public IReadOnlyList<EditorField> Fields { get; } = source.Fields.OfType<JObject>().Select(x => new EditorField(x)).ToArray();
public bool Changed => Fields.Any(x => x.Changed);
}
/// <summary>One detail editor for both desktop roles. Transports supply only actions authorized by the server.</summary>
public sealed class EquipmentEditorModel : ObservableObject
{
private readonly Func<EquipmentTarget, Task<JObject>> read;
private readonly Func<EquipmentTarget, JObject, Task<JObject>> save;
private readonly Func<EquipmentTarget, Task<EquipmentRevision>> revision;
private readonly Func<string, string, Task<bool>> confirm;
private readonly Action<string> notify;
private readonly EditorChangeMonitor changes;
private EquipmentTarget? target;
private string? version, permissionVersion;
private int generation;
private bool busy, conflict;
private string title = UiText.Translate("装备详情"), subtitle = "", summary = "", message = "";
private IReadOnlyList<EquipmentTab> tabs = [];
public string Title { get => title; private set => SetProperty(ref title, value); }
public string Subtitle { get => subtitle; private set => SetProperty(ref subtitle, value); }
public string Summary { get => summary; private set => SetProperty(ref summary, value); }
public string Message { get => message; private set { SetProperty(ref message, value); OnPropertyChanged(nameof(HasMessage)); } }
public bool HasMessage => Message.Length > 0;
public string SelectedTabKey { get; set; } = "basic";
public IReadOnlyList<EquipmentTab> Tabs { get => tabs; private set => SetProperty(ref tabs, value); }
public IEnumerable<EquipmentAction> Actions => Tabs.SelectMany(x => x.Actions);
public bool HasChanges => Actions.Any(x => x.Changed);
public bool HasConflict { get => conflict; private set { SetProperty(ref conflict, value); SaveCommand.NotifyCanExecuteChanged(); } }
public bool IsBusy { get => busy; private set { SetProperty(ref busy, value); OnPropertyChanged(nameof(IsIdle)); SaveCommand.NotifyCanExecuteChanged(); ResetCommand.NotifyCanExecuteChanged(); RefreshCommand.NotifyCanExecuteChanged(); } }
public bool IsIdle => !IsBusy;
public IAsyncRelayCommand<EquipmentAction> SaveCommand { get; }
public IRelayCommand<EquipmentAction> ResetCommand { get; }
public IAsyncRelayCommand RefreshCommand { get; }
public EquipmentEditorModel(Func<EquipmentTarget, Task<JObject>> read,
Func<EquipmentTarget, JObject, Task<JObject>> save,
Func<EquipmentTarget, Task<EquipmentRevision>> revision,
Func<string, string, Task<bool>> confirm, Action<string> notify)
{
this.read = read; this.save = save; this.revision = revision; this.confirm = confirm; this.notify = notify;
changes = new EditorChangeMonitor(CheckChangesAsync);
SaveCommand = new AsyncRelayCommand<EquipmentAction>(SaveAsync, action => !IsBusy && !HasConflict && version is not null && action is not null && Actions.Contains(action));
ResetCommand = new RelayCommand<EquipmentAction>(Reset, _ => !IsBusy);
RefreshCommand = new AsyncRelayCommand(() => RunAsync(async () =>
{
if (target is null || !await CanDiscardAsync()) return;
await ReadAsync(false);
}), () => !IsBusy);
}
public void Attach() => changes.Start();
public void Detach() { changes.Stop(); generation++; }
public Task LoadAsync(EquipmentTarget? next) => RunAsync(async () =>
{
generation++; target = next; version = permissionVersion = null; Tabs = []; HasConflict = false; Message = "";
Title = UiText.Translate("装备详情"); Summary = "";
Subtitle = next is null ? "" : next.AccountName + " · " + InventoryNavigation.Label(next.Category);
if (next is null) { Message = UiText.Translate("请从库存列表选择一件装备。"); return; }
await ReadAsync(false);
});
private async Task ReadAsync(bool preserveDrafts, string? savedKey = null)
{
var selected = target!; var observed = generation; var pageGeneration = changes.Generation;
var data = await read(selected);
var prepared = await Task.Run(() => ((JArray?)data["actions"] ?? []).OfType<JObject>().Select(x =>
new EquipmentAction(new EditorAction(x.Value<string>("key")!, x.Value<string>("label")!, x.Value<string>("description") ?? "", (JArray)x["fields"]!, ""))).ToArray());
if (selected != target || observed != generation || pageGeneration != changes.Generation) throw new OperationCanceledException();
if (data.Value<string>("selectedKey") != selected.Item)
{
Tabs = []; version = null; HasConflict = true; Message = UiText.Translate("此物品已不存在,请返回库存列表。"); return;
}
if (preserveDrafts)
foreach (var action in prepared.Where(x => x.Key != savedKey))
if (Actions.FirstOrDefault(x => x.Key == action.Key) is { } previous)
foreach (var field in action.Fields)
if (previous.Fields.FirstOrDefault(x => x.Key == field.Key) is { Changed: true } draft)
{ field.Value = draft.Value.DeepClone(); field.HasUnresolvedChoice = draft.HasUnresolvedChoice; field.ChoiceQuery = draft.ChoiceQuery; }
version = data.Value<string>("version"); permissionVersion = data.Value<string>("permissionsRevision") ?? permissionVersion;
Title = data.Value<string>("selectedName") ?? Title;
Summary = data.Value<string>("summary") ?? ""; HasConflict = false;
Tabs = prepared.GroupBy(x => Group(x.Key)).OrderBy(x => x.Key.Order)
.Select(x => new EquipmentTab(x.Key.Key, UiText.Translate(x.Key.Title), x.ToArray())).ToArray();
Message = prepared.Length == 0 ? UiText.Translate("管理员未允许编辑此物品。") : "";
SaveCommand.NotifyCanExecuteChanged();
}
private static (int Order, string Key, string Title) Group(string action) => action switch
{
"rename" or "experience" or "max" or "forma" or "mod.rank" or "mature" => (0, "basic", "基础属性"),
"features" => (1, "features", "装备特性"),
var key when key.StartsWith("shards.", StringComparison.Ordinal) => (2, "shards", "源力石"),
"umbra" or "umbra.remove" => (3, "umbra", "Umbra 回响"),
"invigoration" or "invigoration.remove" => (4, "invigoration", "活力注入"),
var key when key.StartsWith("ability.multiple.", StringComparison.Ordinal) => (5, "experimental-abilities", "多技能实验"),
var key when key.StartsWith("ability.", StringComparison.Ordinal) => (5, "abilities", "移植技能"),
"parts" or "valence" => (6, "parts", "部件与元素"),
"remove" => (99, "manage", "物品管理"),
_ => (7, "other", "其他编辑")
};
private Task SaveAsync(EquipmentAction? action) => RunAsync(async () =>
{
if (action is null || target is null || version is null || HasConflict || !Actions.Contains(action)) return;
var values = new JObject(action.Fields.Select(x => new JProperty(x.Key, x.Serialize())));
if ((action.Key is "remove" or "shards.remove" or "umbra.remove" or "invigoration.remove") &&
!await confirm(action.Label, target.AccountName + " · " + Title + "\n" + action.Description)) return;
await save(target, new JObject { ["version"] = version, ["category"] = target.Category, ["item"] = target.Item, ["action"] = action.Key, ["values"] = values });
await ReadAsync(true, action.Key);
notify(UiText.Translate("存档已保存"));
});
private void Reset(EquipmentAction? action)
{
if (action is null || !Actions.Contains(action)) return;
foreach (var field in action.Fields) field.Reset();
OnPropertyChanged(nameof(Tabs));
}
public Task<bool> CanDiscardAsync() => HasChanges
? confirm(UiText.Translate("放弃未保存的修改?"), UiText.Translate("当前装备有未保存的修改。继续后将丢弃这些输入。"))
: Task.FromResult(true);
public async Task CheckChangesAsync()
{
if (!changes.Active || IsBusy || target is null) return;
var observed = version; var selected = target; var pageGeneration = changes.Generation;
try
{
var current = await revision(selected);
if (!changes.Active || IsBusy || target != selected || observed != version || pageGeneration != changes.Generation) return;
if (!current.Enabled) { Revoke(); return; }
if (version is null) { await RunAsync(() => ReadAsync(false)); return; }
if (permissionVersion is not null && permissionVersion != current.Permissions)
{
var previousVersion = version;
var existingConflict = HasConflict;
await RunAsync(() => ReadAsync(true)); // Rebuild from the server's newly filtered action set.
// The save can change after the revision request but before the actual read completes.
// ReadAsync advances the permission version only on success so a failed refresh is retried.
if (HasChanges && (existingConflict || current.Version != previousVersion || version != previousVersion))
{
HasConflict = true;
Message = UiText.Translate("存档已在其他位置更新,输入已保留。请刷新后再保存。");
}
return;
}
permissionVersion = current.Permissions;
if (current.Version == version) return;
if (HasChanges)
{
HasConflict = true;
Message = UiText.Translate("存档已在其他位置更新,输入已保留。请刷新后再保存。");
}
else await RunAsync(() => ReadAsync(false));
}
catch (OperationCanceledException) { }
catch { if (changes.Active && target == selected) Message = UiText.Translate("连接中断,当前输入已保留。"); }
}
public void Revoke(bool forgetTarget = false)
{
generation++; Tabs = []; version = null; HasConflict = true;
if (forgetTarget) target = null;
Message = UiText.Translate("编辑权限已关闭或账号已退出,请返回库存列表。");
}
private async Task RunAsync(Func<Task> operation)
{
if (IsBusy) return;
IsBusy = true; Message = "";
try { await operation(); }
catch (OperationCanceledException) { }
catch (Exception ex) { Message = ex.Message; }
finally { IsBusy = false; }
}
}