返回提交历史
Modified
Admin/XFEWarframe.Admin/Utilities/Diagnostics/DesktopVerification.cs
+11
-1
Modified
Admin/XFEWarframe.Admin/ViewModels/PermissionsPageViewModel.cs
+20
-6
Modified
Admin/XFEWarframe.Admin/Views/PermissionsPage.xaml
+5
-2
Modified
Client/SharedUI/EquipmentEditorModel.cs
+9
-5
Modified
Client/SharedUI/EquipmentEditorVerification.cs
+38
-0
Modified
Client/SharedUI/Localization/native-en.tsv
+4
-0
Modified
Client/SharedUI/Localization/translations.json
+18
-2
Added
Client/XFEWarframe.Player/Utilities/Diagnostics/DesktopVerification.Permissions.cs
+59
-0
Modified
Client/XFEWarframe.Player/Utilities/Diagnostics/DesktopVerification.cs
+9
-5
Modified
Client/XFEWarframe.Player/ViewModels/EditorPageViewModel.Details.cs
+1
-1
Added
Client/XFEWarframe.Player/ViewModels/EditorPageViewModel.Permissions.cs
+51
-0
Modified
Client/XFEWarframe.Player/ViewModels/EditorPageViewModel.ReadOnly.cs
+3
-3
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
XFEstudio/XFEWarframe
玩家端装备编辑权限自动检测与一致性优化
实现玩家端装备编辑权限的自动检测、只读提示与权限变更自动恢复,支持部分授权补全按钮。管理端与玩家端操作字段校验和逐项保存回读保持一致。优化只读模式下的界面交互,统一 SelectorBar 编辑页。新增 WinUI 集成测试,完善文档说明,提升安全性与易用性。
0a318cb
代码差异
18 个文件
+356
-40
@@ -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
@@ -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
{
@@ -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>
@@ -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
@@ -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
{
@@ -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
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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()
@@ -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
}
@@ -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));