XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWarframe

【WinUI】基于Warframe游戏本体的公开服务器以及安装器

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEWarframe

玩家端装备编辑权限自动检测与一致性优化

实现玩家端装备编辑权限的自动检测、只读提示与权限变更自动恢复,支持部分授权补全按钮。管理端与玩家端操作字段校验和逐项保存回读保持一致。优化只读模式下的界面交互,统一 SelectorBar 编辑页。新增 WinUI 集成测试,完善文档说明,提升安全性与易用性。

0a318cb
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

18 个文件 +356 -40
Modified Admin/XFEWarframe.Admin/Utilities/Diagnostics/DesktopVerification.cs +11 -1
@@ -239,9 +239,19 @@ internal static class DesktopVerification
239 239 await permissions.SaveCommand.ExecuteAsync(null);
240 240 if (!permissions.AllowItemEditing || !permissions.Fields.First(x => x.Key == "ability").Value.Value<bool>() || !permissions.Fields.First(x => x.Key == "shards").Value.Value<bool>())
241 241 throw new InvalidOperationException("Item editing switch failed to enable full equipment editing.");
242 permissions.Fields.First(x => x.Key == "ability").Value = new JValue(false);
243 await permissions.SaveCommand.ExecuteAsync(null); permissionsPage.UpdateLayout();
244 var completeButton = (Button)permissionsPage.FindName("CompleteItemEditingButton");
245 if (permissions.CompleteItemEditingVisibility != Visibility.Visible || completeButton.Visibility != Visibility.Visible || !permissions.ItemEditingDescription.Contains("14 / 15", StringComparison.Ordinal))
246 throw new InvalidOperationException("Partial equipment permissions have no visible completion control or enabled count.");
247 ((Microsoft.UI.Xaml.Automation.Provider.IInvokeProvider)new Microsoft.UI.Xaml.Automation.Peers.ButtonAutomationPeer(completeButton)
248 .GetPattern(Microsoft.UI.Xaml.Automation.Peers.PatternInterface.Invoke)).Invoke();
249 await permissions.SaveCommand.ExecuteAsync(null);
250 if (permissions.LastError is not null || permissions.CompleteItemEditingVisibility != Visibility.Collapsed || !permissions.Fields.First(x => x.Key == "ability").Value.Value<bool>())
251 throw new InvalidOperationException("Completing partial equipment permissions did not save all operations.");
242 252 var independent = await App.Service.GetAsync("config");
243 253 if (!independent.Value<bool>("allowPlayerItemGrant") || !independent.Value<bool>("allowPlayerCurrencyGrant")) throw new InvalidOperationException("Item editing changed independent item/currency grant switches.");
244 checks.Add("allow-item-editing: native SettingsCard enables and revokes granular equipment operations independently of grants");
254 checks.Add("allow-item-editing: native SettingsCard enables/revokes operations, reports partial grants and completes them with a native button independently of item/currency grants");
245 255 checks.Add($"community-settings-expanders: {cards.Length} server controls in {groups.Length} groups, inline toggle/save, name/key/group search, expansion and drafts retained, {permissionCards.Length} grouped permissions save/restore");
246 256 }
247 257
Modified Admin/XFEWarframe.Admin/ViewModels/PermissionsPageViewModel.cs +20 -6
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
2 2 using CommunityToolkit.Mvvm.Input;
3 3 using Newtonsoft.Json.Linq;
4 4 using XFEWarframe.Desktop.Editing;
5 using Microsoft.UI.Xaml;
5 6
6 7 namespace XFEWarframe.Admin.ViewModels;
7 8
@@ -12,18 +13,26 @@ public partial class PermissionsPageViewModel : AdminViewModelBase
12 13 // The convenient switch changes the existing granular rules; it never grants currency, new items or account administration.
13 14 private static readonly HashSet<string> ItemPermissions = ["rename", "experience", "forma", "features", "mature", "remove", "mod.rank", "parts", "valence", "shards", "umbra", "invigoration", "ability", "riven", "legacy"];
14 15 private bool updatingItemPermissions;
16 private int EnabledItemPermissions => all.Count(x => ItemPermissions.Contains(x.Key) && x.Value.Value<bool>());
17 public string ItemEditingDescription => UiText.Format("已开启 {0} / {1} 项装备编辑能力。包含战甲、武器、伙伴、MOD 与紫卡;添加物品和货币仍需分别授权。", EnabledItemPermissions, ItemPermissions.Count);
18 public Visibility CompleteItemEditingVisibility => EnabledItemPermissions > 0 && EnabledItemPermissions < ItemPermissions.Count ? Visibility.Visible : Visibility.Collapsed;
15 19 public bool AllowItemEditing
16 20 {
17 21 get => all.Any(x => ItemPermissions.Contains(x.Key) && x.Value.Value<bool>());
18 22 set
19 23 {
24 // Two-way binding may write back the displayed partial state; only an explicit completion grants the rest.
20 25 if (updatingItemPermissions || value == AllowItemEditing) return;
21 updatingItemPermissions = true;
22 try { foreach (var permission in all.Where(x => ItemPermissions.Contains(x.Key))) permission.Value = new JValue(value); }
23 finally { updatingItemPermissions = false; }
24 OnPropertyChanged(); Fields = all.ToArray();
26 SetItemEditing(value);
25 27 }
26 28 }
29 private void SetItemEditing(bool value)
30 {
31 updatingItemPermissions = true;
32 try { foreach (var permission in all.Where(x => ItemPermissions.Contains(x.Key))) permission.Value = new JValue(value); }
33 finally { updatingItemPermissions = false; }
34 NotifyItemPermissions(); Fields = all.ToArray();
35 }
27 36 [ObservableProperty] public partial IReadOnlyList<EditorField> Fields { get; set; } = [];
28 37 [ObservableProperty] public partial string Query { get; set; } = "";
29 38 [ObservableProperty] public partial string Summary { get; set; } = "";
@@ -34,10 +43,15 @@ public partial class PermissionsPageViewModel : AdminViewModelBase
34 43 foreach (var field in all) field.ValueChanged -= PermissionChanged;
35 44 version = data.Value<string>("version"); all = data["fields"]!.OfType<JObject>().Select(x => new EditorField(x)).ToArray();
36 45 foreach (var field in all) field.ValueChanged += PermissionChanged;
37 OnPropertyChanged(nameof(AllowItemEditing));
46 NotifyItemPermissions();
38 47 Summary = $"{all.Count} 项独立编辑权限。氏族操作还会核验成员职位;添加物品与添加货币保留独立开关。"; Fields = all;
39 48 }
40 private void PermissionChanged(object? sender, EventArgs args) { if (!updatingItemPermissions) OnPropertyChanged(nameof(AllowItemEditing)); }
49 private void NotifyItemPermissions()
50 {
51 OnPropertyChanged(nameof(AllowItemEditing)); OnPropertyChanged(nameof(ItemEditingDescription)); OnPropertyChanged(nameof(CompleteItemEditingVisibility));
52 }
53 private void PermissionChanged(object? sender, EventArgs args) { if (!updatingItemPermissions) NotifyItemPermissions(); }
54 [RelayCommand] private void CompleteItemEditing() => SetItemEditing(true);
41 55 [RelayCommand] private Task RefreshAsync() => RunAsync(ReadAsync);
42 56 [RelayCommand] private Task SaveAsync() => RunAsync(async () =>
43 57 {
Modified Admin/XFEWarframe.Admin/Views/PermissionsPage.xaml +5 -2
@@ -17,8 +17,11 @@
17 17 </StackPanel>
18 18 <ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" IsEnabled="{x:Bind ViewModel.IsIdle, Mode=OneWay}">
19 19 <StackPanel Spacing="12">
20 <ct:SettingsCard Header="{loc:Translate Key=ui_917e375bae14f2d2}" Description="{loc:Translate Key=ui_3fcc6ddbd7eea6b8}">
21 <ToggleSwitch IsOn="{x:Bind ViewModel.AllowItemEditing, Mode=TwoWay}" AutomationProperties.AutomationId="AllowItemEditing" AutomationProperties.Name="允许用户编辑物品"/>
20 <ct:SettingsCard Header="{loc:Translate Key=ui_917e375bae14f2d2}" Description="{x:Bind ViewModel.ItemEditingDescription, Mode=OneWay}">
21 <StackPanel Orientation="Horizontal" Spacing="12">
22 <Button x:Name="CompleteItemEditingButton" Content="{loc:Translate Key=ui_40d3cd621b060b7c}" Command="{x:Bind ViewModel.CompleteItemEditingCommand}" Visibility="{x:Bind ViewModel.CompleteItemEditingVisibility, Mode=OneWay}"/>
23 <ToggleSwitch IsOn="{x:Bind ViewModel.AllowItemEditing, Mode=TwoWay}" AutomationProperties.AutomationId="AllowItemEditing" AutomationProperties.Name="允许用户编辑物品"/>
24 </StackPanel>
22 25 </ct:SettingsCard>
23 26 <loc:GroupedSettingsForm Fields="{x:Bind ViewModel.Fields, Mode=OneWay}" Scope="permissions" Query="{x:Bind ViewModel.Query, Mode=OneWay}" EnableSearch="False"/>
24 27 </StackPanel>
Modified Client/SharedUI/EquipmentEditorModel.cs +9 -5
@@ -138,19 +138,22 @@ public sealed class EquipmentEditorModel : ObservableObject
138 138
139 139 public async Task CheckChangesAsync()
140 140 {
141 if (!changes.Active || IsBusy || target is null || version is null) return;
141 if (!changes.Active || IsBusy || target is null) return;
142 142 var observed = version; var selected = target; var pageGeneration = changes.Generation;
143 143 try
144 144 {
145 145 var current = await revision(selected);
146 146 if (!changes.Active || IsBusy || target != selected || observed != version || pageGeneration != changes.Generation) return;
147 147 if (!current.Enabled) { Revoke(); return; }
148 if (version is null) { await RunAsync(() => ReadAsync(false)); return; }
148 149 if (permissionVersion is not null && permissionVersion != current.Permissions)
149 150 {
150 var inventoryChangedWithDraft = current.Version != version && HasChanges;
151 permissionVersion = current.Permissions;
151 var previousVersion = version;
152 var existingConflict = HasConflict;
152 153 await RunAsync(() => ReadAsync(true)); // Rebuild from the server's newly filtered action set.
153 if (inventoryChangedWithDraft && HasChanges)
154 // The save can change after the revision request but before the actual read completes.
155 // ReadAsync advances the permission version only on success so a failed refresh is retried.
156 if (HasChanges && (existingConflict || current.Version != previousVersion || version != previousVersion))
154 157 {
155 158 HasConflict = true;
156 159 Message = UiText.Translate("存档已在其他位置更新,输入已保留。请刷新后再保存。");
@@ -170,9 +173,10 @@ public sealed class EquipmentEditorModel : ObservableObject
170 173 catch { if (changes.Active && target == selected) Message = UiText.Translate("连接中断,当前输入已保留。"); }
171 174 }
172 175
173 public void Revoke()
176 public void Revoke(bool forgetTarget = false)
174 177 {
175 178 generation++; Tabs = []; version = null; HasConflict = true;
179 if (forgetTarget) target = null;
176 180 Message = UiText.Translate("编辑权限已关闭或账号已退出,请返回库存列表。");
177 181 }
178 182
Modified Client/SharedUI/EquipmentEditorVerification.cs +38 -0
@@ -66,6 +66,44 @@ internal static class EquipmentEditorVerification
66 66 if (model.HasMessage || model.Actions.Single(x => x.Key == "experience").Fields.Single().Value.Value<long>() != experience)
67 67 throw new InvalidOperationException("Reset wrote an unsaved draft to the server.");
68 68 await VerifyAbilitySearchAsync(page, view, verifyLiveInput);
69 await VerifyPermissionReadRaceAsync();
70 }
71 private static async Task VerifyPermissionReadRaceAsync()
72 {
73 var permissions = "p1"; var inventoryVersion = "v1"; var failRead = false;
74 var target = new EquipmentTarget("fixture", "Fixture", "Suits", "suit");
75 var model = new EquipmentEditorModel(_ =>
76 {
77 if (failRead) throw new IOException("isolated transient read failure");
78 return Task.FromResult(new JObject
79 {
80 ["version"] = inventoryVersion, ["permissionsRevision"] = permissions, ["selectedKey"] = "suit",
81 ["actions"] = new JArray(new JObject
82 {
83 ["key"] = "experience", ["label"] = "Experience", ["fields"] = new JArray(new JObject
84 { ["key"] = "xp", ["type"] = "number", ["value"] = 10, ["min"] = 0, ["max"] = 900000 })
85 })
86 });
87 }, (_, _) => throw new InvalidOperationException("This concurrency check must not save."),
88 _ => Task.FromResult(new EquipmentRevision("v1", permissions, true)), (_, _) => Task.FromResult(false), _ => { });
89 model.Attach();
90 try
91 {
92 await model.LoadAsync(target);
93 model.Actions.Single().Fields.Single().Value = new JValue(77);
94 permissions = "p2"; inventoryVersion = "v2"; failRead = true;
95 await model.CheckChangesAsync();
96 if (!model.HasMessage || model.Actions.Single().Fields.Single().Value.Value<long>() != 77)
97 throw new InvalidOperationException("A failed permission refresh discarded a draft.");
98 failRead = false;
99 await model.CheckChangesAsync();
100 if (!model.HasConflict || !model.HasChanges || model.SaveCommand.CanExecute(model.Actions.Single()) || model.Actions.Single().Fields.Single().Value.Value<long>() != 77)
101 throw new InvalidOperationException("Permission refresh did not retry or detect inventory changed between revision and read.");
102 permissions = "p3";
103 await model.CheckChangesAsync();
104 if (!model.HasConflict) throw new InvalidOperationException("A further permission update cleared the unresolved inventory conflict.");
105 }
106 finally { model.Detach(); }
69 107 }
70 108 private static async Task VerifyAbilitySearchAsync(Page page, EquipmentEditorView view, bool verifyLiveInput)
71 109 {
Modified Client/SharedUI/Localization/native-en.tsv +4 -0
@@ -516,3 +516,7 @@ MOD 与紫卡编辑 Mods and Riven editing
516 516 将 Plexus 提升至满级;尚未拥有时先创建。 Maximize the Plexus, creating it if needed.
517 517 将当前账号的全部邮件标记为已读。 Mark all messages for the current account as read.
518 518 选择当前支持的集团,也可以取消支持。 Select a supported Syndicate or stop supporting one.
519 当前为只读库存。请管理员在“玩家编辑权限”中开启“允许用户编辑物品”并保存;添加物品权限与编辑权限分别设置。 This inventory is read-only. Ask an administrator to enable Allow players to edit items under Player editing permissions and save. Adding items and editing items have separate permissions.
520 当前物品没有已授权的编辑操作。管理员保存权限后,此页面会自动更新。 No editing operations are authorized for this item. This page updates automatically after an administrator saves permissions.
521 已开启 {0} / {1} 项装备编辑能力。包含战甲、武器、伙伴、MOD 与紫卡;添加物品和货币仍需分别授权。 {0} / {1} equipment editing permissions enabled. Covers Warframes, weapons, companions, mods and Rivens; adding items and currency requires separate permissions.
522 补全装备编辑权限 Enable all equipment editing permissions
Modified Client/SharedUI/Localization/translations.json +18 -2
@@ -2974,6 +2974,10 @@
2974 2974 "将 Plexus 提升至满级;尚未拥有时先创建。": "Maximize the Plexus, creating it if needed.",
2975 2975 "将当前账号的全部邮件标记为已读。": "Mark all messages for the current account as read.",
2976 2976 "选择当前支持的集团,也可以取消支持。": "Select a supported Syndicate or stop supporting one.",
2977 "当前为只读库存。请管理员在“玩家编辑权限”中开启“允许用户编辑物品”并保存;添加物品权限与编辑权限分别设置。": "This inventory is read-only. Ask an administrator to enable Allow players to edit items under Player editing permissions and save. Adding items and editing items have separate permissions.",
2978 "当前物品没有已授权的编辑操作。管理员保存权限后,此页面会自动更新。": "No editing operations are authorized for this item. This page updates automatically after an administrator saves permissions.",
2979 "已开启 {0} / {1} 项装备编辑能力。包含战甲、武器、伙伴、MOD 与紫卡;添加物品和货币仍需分别授权。": "{0} / {1} equipment editing permissions enabled. Covers Warframes, weapons, companions, mods and Rivens; adding items and currency requires separate permissions.",
2980 "补全装备编辑权限": "Enable all equipment editing permissions",
2977 2981 "ui_aff95a53b0639e8f": "My account",
2978 2982 "ui_d4249d4b75b932d0": "Your identity, collection and adventures.",
2979 2983 "ui_1f43499af69d9b9f": "Sign in to view your game profile and inventory.",
@@ -3491,7 +3495,11 @@
3491 3495 "ui_6c49c121388d373c": "Level up Helminth and unlock its supported abilities.",
3492 3496 "ui_48d41386609155e8": "Maximize the Plexus, creating it if needed.",
3493 3497 "ui_57aa908172c4fffc": "Mark all messages for the current account as read.",
3494 "ui_24201cc263b0d4f1": "Select a supported Syndicate or stop supporting one."
3498 "ui_24201cc263b0d4f1": "Select a supported Syndicate or stop supporting one.",
3499 "ui_9dd642aa5e63fa3b": "This inventory is read-only. Ask an administrator to enable Allow players to edit items under Player editing permissions and save. Adding items and editing items have separate permissions.",
3500 "ui_e6787a04764a5cac": "No editing operations are authorized for this item. This page updates automatically after an administrator saves permissions.",
3501 "ui_a98ee44596cd8401": "{0} / {1} equipment editing permissions enabled. Covers Warframes, weapons, companions, mods and Rivens; adding items and currency requires separate permissions.",
3502 "ui_40d3cd621b060b7c": "Enable all equipment editing permissions"
3495 3503 },
3496 3504 "es": {
3497 3505 "general_inventoryUpdateNote": "[UNTRANSLATED] Note: To see changes in-game, you need to resync your inventory, e.g. by visiting a dojo/relay or relogging.",
@@ -11636,6 +11644,10 @@
11636 11644 "将 Plexus 提升至满级;尚未拥有时先创建。": "将 Plexus 提升至满级;尚未拥有时先创建。",
11637 11645 "将当前账号的全部邮件标记为已读。": "将当前账号的全部邮件标记为已读。",
11638 11646 "选择当前支持的集团,也可以取消支持。": "选择当前支持的集团,也可以取消支持。",
11647 "当前为只读库存。请管理员在“玩家编辑权限”中开启“允许用户编辑物品”并保存;添加物品权限与编辑权限分别设置。": "当前为只读库存。请管理员在“玩家编辑权限”中开启“允许用户编辑物品”并保存;添加物品权限与编辑权限分别设置。",
11648 "当前物品没有已授权的编辑操作。管理员保存权限后,此页面会自动更新。": "当前物品没有已授权的编辑操作。管理员保存权限后,此页面会自动更新。",
11649 "已开启 {0} / {1} 项装备编辑能力。包含战甲、武器、伙伴、MOD 与紫卡;添加物品和货币仍需分别授权。": "已开启 {0} / {1} 项装备编辑能力。包含战甲、武器、伙伴、MOD 与紫卡;添加物品和货币仍需分别授权。",
11650 "补全装备编辑权限": "补全装备编辑权限",
11639 11651 "ui_aff95a53b0639e8f": "我的账号",
11640 11652 "ui_d4249d4b75b932d0": "你的身份、收藏与每一段旅程。",
11641 11653 "ui_1f43499af69d9b9f": "登录后查看你的游戏名片与仓库。",
@@ -12153,6 +12165,10 @@
12153 12165 "ui_6c49c121388d373c": "提升 Helminth 等级并解锁其支持的能力。",
12154 12166 "ui_48d41386609155e8": "将 Plexus 提升至满级;尚未拥有时先创建。",
12155 12167 "ui_57aa908172c4fffc": "将当前账号的全部邮件标记为已读。",
12156 "ui_24201cc263b0d4f1": "选择当前支持的集团,也可以取消支持。"
12168 "ui_24201cc263b0d4f1": "选择当前支持的集团,也可以取消支持。",
12169 "ui_9dd642aa5e63fa3b": "当前为只读库存。请管理员在“玩家编辑权限”中开启“允许用户编辑物品”并保存;添加物品权限与编辑权限分别设置。",
12170 "ui_e6787a04764a5cac": "当前物品没有已授权的编辑操作。管理员保存权限后,此页面会自动更新。",
12171 "ui_a98ee44596cd8401": "已开启 {0} / {1} 项装备编辑能力。包含战甲、武器、伙伴、MOD 与紫卡;添加物品和货币仍需分别授权。",
12172 "ui_40d3cd621b060b7c": "补全装备编辑权限"
12157 12173 }
12158 12174 }
Added Client/XFEWarframe.Player/Utilities/Diagnostics/DesktopVerification.Permissions.cs +59 -0
@@ -0,0 +1,59 @@
1 using Microsoft.UI.Xaml;
2 using Microsoft.UI.Xaml.Controls;
3 using Newtonsoft.Json.Linq;
4 using XFEWarframe.Desktop.Editing;
5 using XFEWarframe.Player.Profiles.CrossVersionProfiles;
6 using XFEWarframe.Player.Views;
7
8 namespace XFEWarframe.Player.Utilities.Diagnostics;
9
10 internal static partial class DesktopVerification
11 {
12 private static async Task PollEquipmentUntilAsync(EquipmentEditorModel model, Func<bool> ready)
13 {
14 // An automatic timer may already be refreshing. Wait for that operation instead of asserting an intermediate state.
15 for (var i = 0; i < 100; i++)
16 {
17 await model.CheckChangesAsync();
18 if (!model.IsBusy && ready()) return;
19 await Task.Delay(30);
20 }
21 throw new InvalidOperationException($"Permission polling did not settle: busy={model.IsBusy}; tabs={model.Tabs.Count}; message={model.Message}");
22 }
23 private static async Task VerifyInventoryPermissionRecoveryAsync(EditorPage page)
24 {
25 // Only the opt-in isolated harness has this administrative transport; normal player requests use their own token.
26 var origin = new Uri(AppPath.Argument("verify-api")!);
27 if (!origin.IsLoopback) throw new InvalidOperationException("Permission verification requires the isolated loopback server.");
28 var config = JObject.Parse(await File.ReadAllTextAsync(Path.Combine(AppPath.Argument("root")!, "server.json")));
29 using var client = new System.Net.Http.HttpClient { BaseAddress = origin };
30 client.DefaultRequestHeaders.Authorization = new("Bearer", config.Value<string>("AdminToken")!);
31 var before = JObject.Parse(await client.GetStringAsync("control/v1/editor/permissions"));
32 var saved = new JObject(before["fields"]!.Select(x => new JProperty(x.Value<string>("key")!, x.Value<bool>("value"))));
33 async Task UpdateAsync(JObject values)
34 {
35 var current = JObject.Parse(await client.GetStringAsync("control/v1/editor/permissions"));
36 using var response = await client.PostAsync("control/v1/editor/permissions", new System.Net.Http.StringContent(
37 new JObject { ["version"] = current["version"]!.DeepClone(), ["values"] = values }.ToString(), System.Text.Encoding.UTF8, "application/json"));
38 response.EnsureSuccessStatusCode();
39 }
40 var vm = page.ViewModel;
41 var item = vm.SelectedItem!.Key; var query = vm.Query; var category = vm.Category!.Key;
42 var notice = (InfoBar)page.FindName("PermissionNotice");
43 try
44 {
45 await UpdateAsync(new JObject(saved.Properties().Select(x => new JProperty(x.Name, false))));
46 await vm.CheckChangesAsync(); page.UpdateLayout();
47 if (vm.LastError is not null || vm.Account.EditingEnabled || !vm.HasPermissionNotice || !notice.IsOpen ||
48 vm.DetailButtonVisibility != Visibility.Collapsed || vm.Items.Any(x => x.CanEdit) || vm.Items.Count == 0)
49 throw new InvalidOperationException("Revocation did not preserve a read-only inventory and explain the missing edit permission: " + vm.LastError);
50 vm.EditItemCommand.Execute(null);
51 if (!ReferenceEquals(page.Frame.Content, page)) throw new InvalidOperationException("Read-only inventory opened the equipment editor.");
52 }
53 finally { await UpdateAsync(saved); await vm.CheckChangesAsync(); }
54 page.UpdateLayout();
55 if (vm.LastError is not null || !vm.Account.EditingEnabled || vm.HasPermissionNotice || notice.IsOpen ||
56 vm.DetailButtonVisibility != Visibility.Visible || vm.SelectedItem?.Key != item || vm.Query != query || vm.Category?.Key != category)
57 throw new InvalidOperationException("Restoring permissions failed to restore the edit entry and selected suit without manual refresh: " + vm.LastError);
58 }
59 }
Modified Client/XFEWarframe.Player/Utilities/Diagnostics/DesktopVerification.cs +9 -5
@@ -19,7 +19,7 @@ using XFEWarframe.Desktop.Editing;
19 19 namespace XFEWarframe.Player.Utilities.Diagnostics;
20 20
21 21 // Opt-in isolated integration check; --verify-dialogs also exercises visible native modals without launching the game.
22 internal static class DesktopVerification
22 internal static partial class DesktopVerification
23 23 {
24 24 public static List<string> BindingErrors { get; } = [];
25 25 public static async Task RunAsync(string reportPath)
@@ -397,6 +397,8 @@ internal static class DesktopVerification
397 397 if (editor.Actions.Count == 0) throw new InvalidOperationException("The newly added Warframe cannot be edited.");
398 398 editor.Query = "Excalibur"; await editor.RefreshCommand.ExecuteAsync(null);
399 399 editor.SelectedItem = editor.Items.First(); var selectedKey = editor.SelectedItem.Key;
400 await VerifyInventoryPermissionRecoveryAsync((EditorPage)navigation.NavigationService.Frame.Content);
401 checks.Add("inventory-permission-recovery: editing to readonly and back via revision polling, visible explanation, same query and selected suit, no manual refresh");
400 402 editor.EditItemCommand.Execute(null);
401 403 if (navigation.NavigationService.Frame!.Content is not EquipmentPage details) throw new InvalidOperationException("Editing did not navigate to the dedicated page.");
402 404 if (details.ViewModel.LoadCommand.ExecutionTask is Task detailLoad) await detailLoad;
@@ -444,17 +446,19 @@ internal static class DesktopVerification
444 446 var field = model.Actions.Single(x => x.Key == "experience").Fields.Single();
445 447 var draft = field.Value.Value<long>() + 321; field.Value = new JValue(draft);
446 448 var partial = new JObject(saved.Properties().Select(x => new JProperty(x.Name, x.Name == "experience")));
447 await UpdateAsync(partial); await App.Accounts.GrantAsync("RegularCredits", 1); await model.CheckChangesAsync();
449 await UpdateAsync(partial); await App.Accounts.GrantAsync("RegularCredits", 1);
450 await PollEquipmentUntilAsync(model, () => model.Tabs.Count == 1 && model.Actions.All(x => x.Key is "experience" or "max"));
448 451 if (model.Actions.Any(x => x.Key is not ("experience" or "max")) || model.Tabs.Count != 1 || !model.HasConflict ||
449 452 model.Actions.Single(x => x.Key == "experience").Fields.Single().Value.Value<long>() != draft)
450 throw new InvalidOperationException("Partial permissions and a concurrent inventory change did not preserve an authorized draft safely.");
451 partial["experience"] = false; await UpdateAsync(partial); await model.CheckChangesAsync();
453 throw new InvalidOperationException($"Partial permissions and a concurrent inventory change did not preserve an authorized draft safely. Actions={string.Join(',', model.Actions.Select(x => x.Key))}; tabs={model.Tabs.Count}; conflict={model.HasConflict}; busy={model.IsBusy}; expectedDraft={draft}; actualDraft={model.Actions.FirstOrDefault(x => x.Key == "experience")?.Fields.FirstOrDefault()?.Value}; message={model.Message}");
454 partial["experience"] = false; await UpdateAsync(partial);
455 await PollEquipmentUntilAsync(model, () => model.Tabs.Count == 0);
452 456 if (model.Tabs.Count != 0 || ((EquipmentEditorView)page.FindName("EditorView")).Selector.Items.Count != 0)
453 457 throw new InvalidOperationException("Revoked permissions left equipment controls visible.");
454 458 }
455 459 finally
456 460 {
457 await UpdateAsync(saved); await App.Accounts.RefreshAsync(); await model.RefreshCommand.ExecuteAsync(null);
461 await UpdateAsync(saved); await PollEquipmentUntilAsync(model, () => model.Tabs.Count >= 6);
458 462 }
459 463 if (model.HasMessage || model.Tabs.Count < 6) throw new InvalidOperationException("Restored permissions did not reload the shared equipment editor.");
460 464 }
Modified Client/XFEWarframe.Player/ViewModels/EditorPageViewModel.Details.cs +1 -1
@@ -6,7 +6,7 @@ namespace XFEWarframe.Player.ViewModels;
6 6
7 7 public partial class EditorPageViewModel
8 8 {
9 public Visibility DetailButtonVisibility => Account.IsLoggedIn && Account.EditingEnabled && SelectedItem?.CanEdit == true ? Visibility.Visible : Visibility.Collapsed;
9 public Visibility DetailButtonVisibility => !permissionRefreshPending && Account.IsLoggedIn && Account.EditingEnabled && SelectedItem?.CanEdit == true ? Visibility.Visible : Visibility.Collapsed;
10 10 public Visibility ActionDialogVisibility => Account.IsLoggedIn && Account.EditingEnabled && Category?.Key != "QuestKeys" && SelectedItem is null && Actions.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
11 11 private void NotifyDetailNavigation() { OnPropertyChanged(nameof(DetailButtonVisibility)); OnPropertyChanged(nameof(ActionDialogVisibility)); }
12 12 [RelayCommand] private void EditItem()
Added Client/XFEWarframe.Player/ViewModels/EditorPageViewModel.Permissions.cs +51 -0
@@ -0,0 +1,51 @@
1 using XFEWarframe.Desktop.Editing;
2
3 namespace XFEWarframe.Player.ViewModels;
4
5 public partial class EditorPageViewModel
6 {
7 private bool permissionRefreshPending;
8 private string? permissionSelectedItem;
9 private int permissionSelectedIndex;
10 public string PermissionNotice => !Account.IsLoggedIn || IsCatalogMode ? "" : !Account.EditingEnabled
11 ? UiText.Translate("当前为只读库存。请管理员在“玩家编辑权限”中开启“允许用户编辑物品”并保存;添加物品权限与编辑权限分别设置。")
12 : !permissionRefreshPending && SelectedItem?.CanEdit == false
13 ? UiText.Translate("当前物品没有已授权的编辑操作。管理员保存权限后,此页面会自动更新。") : "";
14 public bool HasPermissionNotice => PermissionNotice.Length > 0;
15 private void NotifyPermissionNotice()
16 {
17 OnPropertyChanged(nameof(PermissionNotice));
18 OnPropertyChanged(nameof(HasPermissionNotice));
19 }
20 private void InvalidatePermissions()
21 {
22 // Keep the selected identity across consecutive notifications from one account response.
23 if (!permissionRefreshPending)
24 {
25 permissionSelectedItem = SelectedItem?.Key;
26 permissionSelectedIndex = offset + Math.Max(0, SelectedItem is null ? 0 : Items.IndexOf(SelectedItem));
27 }
28 Reset();
29 permissionRefreshPending = Account.IsLoggedIn;
30 if (!Account.IsLoggedIn) permissionSelectedItem = null;
31 NotifyDetailNavigation(); NotifyPermissionNotice();
32 }
33 private Task RefreshPermissionsAsync() => RunAsync(async () =>
34 {
35 if (!permissionRefreshPending || !Account.IsLoggedIn) return;
36 permissionRefreshPending = false;
37 var item = permissionSelectedItem; permissionSelectedItem = null;
38 SelectedAccount = new(Account.AccountId, Account.DisplayName);
39 offset = permissionSelectedIndex / PageSize * PageSize;
40 try { await ReadAsync(item); }
41 catch
42 {
43 if (!permissionRefreshPending && Account.IsLoggedIn)
44 {
45 permissionRefreshPending = true; permissionSelectedItem = item;
46 }
47 throw;
48 }
49 finally { NotifyDetailNavigation(); NotifyPermissionNotice(); }
50 });
51 }
Modified Client/XFEWarframe.Player/ViewModels/EditorPageViewModel.ReadOnly.cs +3 -3
@@ -6,7 +6,7 @@ namespace XFEWarframe.Player.ViewModels;
6 6 public partial class EditorPageViewModel
7 7 {
8 8 private int PageSize => Account.EditingEnabled ? 40 : 100;
9 private async Task ReadOnlyAsync()
9 private async Task ReadOnlyAsync(string? selectedKey = null)
10 10 {
11 11 if (!Account.IsLoggedIn) { Reset(); return; }
12 12 var category = Category?.Key ?? "Suits";
@@ -20,8 +20,8 @@ public partial class EditorPageViewModel
20 20 ListVisibility = Visibility.Visible;
21 21 Items.Clear(); foreach (var item in data.Items)
22 22 Items.Add(new(item.Id.Length > 0 ? item.Id : item.Type, item.Name,
23 UiText.Format("经验 {0:N0} · 数量 {1:N0}", item.Xp, item.Count), item.Tags));
24 SelectedItem = Items.FirstOrDefault();
23 UiText.Format("经验 {0:N0} · 数量 {1:N0}", item.Xp, item.Count), item.Tags, CanEdit: false));
24 SelectedItem = Items.FirstOrDefault(x => x.Key == selectedKey) ?? Items.FirstOrDefault();
25 25 if (SelectedItem is null) { SelectionLabel = PageTitle; Description = UiText.Translate("当前分类没有物品。"); }
26 26 total = data.Total; CanPrevious = offset > 0; CanNext = offset + PageSize < total;
27 27 PageLabel = UiText.Format("共 {0:N0} 项 · 第 {1} / {2} 页", total, offset / PageSize + 1, Math.Max(1, (total + PageSize - 1) / PageSize));
Modified Client/XFEWarframe.Player/ViewModels/EditorPageViewModel.cs +15 -6
Modified Client/XFEWarframe.Player/ViewModels/EquipmentPageViewModel.cs +7 -4
Modified Client/XFEWarframe.Player/Views/EditorPage.xaml +1 -0
Modified README.md +6 -4
Added Server/Tests/XFEWarframe.Tests/PlayerEquipmentParityTests.cs +81 -0
Modified docs/PLAYER-ACCOUNT.md +18 -1