Skip to content

Commit 34a8132

Browse files
committed
Add local registration and per-user profiles
1 parent 9ae752b commit 34a8132

23 files changed

Lines changed: 1035 additions & 69 deletions

UpdateLog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
## 更新日志
22

33
### V12.0.2.6(2026-05-15)
4+
- 😄[新增]-新增本地账号注册能力,支持多实例运行,同时对同一账号启用会话互斥,避免同账号配置被多个进程同时写入。
5+
- 😄[新增]-新增账号级用户配置目录,按登录账号保存搜索历史、首页常用工具、工具使用次数和工具字段缓存,提升重复使用工具时的连续性。
6+
- 🔨[优化]-标题栏搜索框支持展示最近 10 条搜索关键字下拉提示,搜索历史随当前登录账号切换。
7+
- 🐛[修复]-修复关闭默认反射序列化后,本地账号/profile JSON 与部分 JSON 类开发工具在运行时可能报错的问题。
48
- 😄[新增]-新增 CSV/JSON/Markdown 表格、QueryString、HTTP Header、INI、XPath、JSON Path、SemVer、NanoID、UUID v5、Hex Dump、Regex Replace、Env 转 JSON 等本地开发工具,进一步覆盖程序员高频处理场景。
59
- 🔨[优化]-登录改为纯本地 App.config 账号校验,默认账号为 `CodeWF`,密码仅保存 MD5 哈希;移除访客登录和网络登录状态提示。
610
- 🔨[优化]-主窗口标题栏新增用户标识与设置快捷入口,保留左侧底部设置入口作为全局偏好入口,让账号信息与应用设置职责更清晰。
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using CodeWF.Core.Models;
2+
3+
namespace CodeWF.Core.IServices;
4+
5+
public interface IUserProfileService
6+
{
7+
event EventHandler? ProfileChanged;
8+
9+
string? CurrentUsername { get; }
10+
11+
string? CurrentProfileDirectory { get; }
12+
13+
IReadOnlyList<string> SearchHistory { get; }
14+
15+
IReadOnlyList<UserToolUsage> FrequentTools { get; }
16+
17+
string EnsureProfileDirectory(string username);
18+
19+
void Activate(string username);
20+
21+
void Deactivate();
22+
23+
void RecordSearch(string keyword);
24+
25+
void RecordToolUsage(ToolMenuItem toolMenuItem);
26+
27+
IReadOnlyDictionary<string, string> GetToolFieldValues(string toolId);
28+
29+
void SaveToolFieldValues(string toolId, IReadOnlyDictionary<string, string> values);
30+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace CodeWF.Core.Models;
2+
3+
public sealed class UserToolUsage
4+
{
5+
public string ViewName { get; set; } = string.Empty;
6+
7+
public string? Name { get; set; }
8+
9+
public string? Description { get; set; }
10+
11+
public string? Icon { get; set; }
12+
13+
public int Count { get; set; }
14+
15+
public DateTimeOffset LastUsedAt { get; set; } = DateTimeOffset.UtcNow;
16+
}

src/CodeWF.Modules.ToolFramework/Services/ToolAlgorithms.cs

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
using System.Security.Cryptography;
2525
using System.Text;
2626
using System.Text.Json;
27+
using System.Text.Json.Serialization.Metadata;
2728
using System.Text.RegularExpressions;
2829
using System.Xml;
2930
using System.Xml.Linq;
@@ -36,19 +37,28 @@ namespace CodeWF.Modules.ToolFramework.Services;
3637

3738
public static partial class ToolAlgorithms
3839
{
39-
private static readonly JsonSerializerOptions PrettyJsonOptions = new() { WriteIndented = true };
40-
private static readonly JsonSerializerOptions CompactJsonOptions = new() { WriteIndented = false };
40+
private static readonly DefaultJsonTypeInfoResolver JsonTypeInfoResolver = new();
41+
private static readonly JsonSerializerOptions PrettyJsonOptions = new()
42+
{
43+
WriteIndented = true,
44+
TypeInfoResolver = JsonTypeInfoResolver
45+
};
46+
private static readonly JsonSerializerOptions CompactJsonOptions = new()
47+
{
48+
WriteIndented = false,
49+
TypeInfoResolver = JsonTypeInfoResolver
50+
};
4151
private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
4252
private static readonly Regex SlugUnsafeRegex = new(@"[^a-zA-Z0-9\-_\s]", RegexOptions.Compiled);
4353

4454
private static readonly Lazy<Dictionary<string, string>> OuiData = new(() =>
45-
JsonSerializer.Deserialize<Dictionary<string, string>>(ReadResource("oui-data.json")) ?? []);
55+
JsonSerializer.Deserialize<Dictionary<string, string>>(ReadResource("oui-data.json"), CompactJsonOptions) ?? []);
4656

4757
private static readonly Lazy<Dictionary<string, MimeDbEntry>> MimeDb = new(() =>
48-
JsonSerializer.Deserialize<Dictionary<string, MimeDbEntry>>(ReadResource("mime-db.json")) ?? []);
58+
JsonSerializer.Deserialize<Dictionary<string, MimeDbEntry>>(ReadResource("mime-db.json"), CompactJsonOptions) ?? []);
4959

5060
private static readonly Lazy<Dictionary<string, EmojiInfo>> EmojiData = new(() =>
51-
JsonSerializer.Deserialize<Dictionary<string, EmojiInfo>>(ReadResource("emoji-data.json")) ?? []);
61+
JsonSerializer.Deserialize<Dictionary<string, EmojiInfo>>(ReadResource("emoji-data.json"), CompactJsonOptions) ?? []);
5262

5363
private static readonly Dictionary<string, string> CssColors = new(StringComparer.OrdinalIgnoreCase)
5464
{
@@ -1013,7 +1023,9 @@ public static Task StringEscapeAsync(ToolRunContext context, CancellationToken t
10131023
var escape = context.Option("mode") == "Escape";
10141024
var result = context.Option("format") switch
10151025
{
1016-
"JSON" => escape ? JsonSerializer.Serialize(text) : JsonSerializer.Deserialize<string>(text) ?? string.Empty,
1026+
"JSON" => escape
1027+
? JsonSerializer.Serialize(text, CompactJsonOptions)
1028+
: JsonSerializer.Deserialize<string>(text, CompactJsonOptions) ?? string.Empty,
10171029
"C#" => escape ? EscapeCSharpString(text) : UnescapeCStyle(text),
10181030
"HTML" => escape ? WebUtility.HtmlEncode(text) : WebUtility.HtmlDecode(text),
10191031
"URL" => escape ? WebUtility.UrlEncode(text) : WebUtility.UrlDecode(text),

src/CodeWF.Modules.ToolFramework/ViewModels/ToolViewModel.cs

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Lang.Avalonia;
99
using Prism.Regions;
1010
using ReactiveUI;
11+
using System.Globalization;
1112
using System.Collections.ObjectModel;
1213
using System.Reactive;
1314
using System.Reactive.Disposables;
@@ -19,15 +20,20 @@ public sealed class ToolViewModel : ReactiveObject, INavigationAware, IDisposabl
1920
{
2021
private readonly ToolRegistry _registry;
2122
private readonly IFileChooserService _fileChooserService;
23+
private readonly IUserProfileService _userProfileService;
2224
private readonly CompositeDisposable _subscriptions = new();
2325
private CancellationTokenSource? _runCts;
2426
private ToolSpec? _currentTool;
2527
private string _errorMessage = string.Empty;
2628

27-
public ToolViewModel(ToolRegistry registry, IFileChooserService fileChooserService)
29+
public ToolViewModel(
30+
ToolRegistry registry,
31+
IFileChooserService fileChooserService,
32+
IUserProfileService userProfileService)
2833
{
2934
_registry = registry;
3035
_fileChooserService = fileChooserService;
36+
_userProfileService = userProfileService;
3137
RunCommand = ReactiveCommand.CreateFromTask(RunAsync);
3238
BrowsePathCommand = ReactiveCommand.CreateFromTask<ToolField>(BrowsePathAsync);
3339
CopyOutputCommand = ReactiveCommand.CreateFromTask<ToolOutput>(CopyOutputAsync);
@@ -103,29 +109,22 @@ private void LoadTool(string id)
103109
return;
104110
}
105111

112+
var savedFieldValues = _userProfileService.GetToolFieldValues(_currentTool.Id);
106113
foreach (var field in _currentTool.Fields)
107114
{
115+
RestoreFieldValue(field, savedFieldValues);
108116
Fields.Add(field);
109117

118+
AddFieldCacheSubscriptions(field);
119+
110120
if (!_currentTool.AutoRun)
111121
{
112122
continue;
113123
}
114124

115-
_subscriptions.Add(field.WhenAnyValue(x => x.Text)
116-
.Skip(1)
117-
.Throttle(TimeSpan.FromMilliseconds(300))
118-
.Subscribe(_ => Dispatcher.UIThread.Post(async () => await RunAsync())));
119-
_subscriptions.Add(field.WhenAnyValue(x => x.Number)
120-
.Skip(1)
125+
_subscriptions.Add(CreateFieldChangeSignal(field)
121126
.Throttle(TimeSpan.FromMilliseconds(300))
122127
.Subscribe(_ => Dispatcher.UIThread.Post(async () => await RunAsync())));
123-
_subscriptions.Add(field.WhenAnyValue(x => x.Boolean)
124-
.Skip(1)
125-
.Subscribe(_ => Dispatcher.UIThread.Post(async () => await RunAsync())));
126-
_subscriptions.Add(field.WhenAnyValue(x => x.SelectedOption)
127-
.Skip(1)
128-
.Subscribe(_ => Dispatcher.UIThread.Post(async () => await RunAsync())));
129128
}
130129

131130
foreach (var output in _currentTool.Outputs)
@@ -165,6 +164,7 @@ private async Task RunAsync()
165164
ErrorMessage = string.Empty;
166165
this.RaisePropertyChanged(nameof(HasError));
167166
await _currentTool.RunAsync(context, token);
167+
SaveCurrentFieldValues();
168168
}
169169
catch (OperationCanceledException)
170170
{
@@ -221,6 +221,82 @@ public void OnKeyDown(string key, string physicalKey, string modifiers)
221221
context.SetText("result", $"Key: {key}{Environment.NewLine}Physical key: {physicalKey}{Environment.NewLine}Modifiers: {modifiers}");
222222
}
223223

224+
private void AddFieldCacheSubscriptions(ToolField field)
225+
{
226+
_subscriptions.Add(CreateFieldChangeSignal(field)
227+
.Throttle(TimeSpan.FromMilliseconds(400))
228+
.Subscribe(_ => SaveCurrentFieldValues()));
229+
}
230+
231+
private static IObservable<Unit> CreateFieldChangeSignal(ToolField field)
232+
{
233+
return Observable.Merge(
234+
field.WhenAnyValue(x => x.Text).Skip(1).Select(_ => Unit.Default),
235+
field.WhenAnyValue(x => x.Number).Skip(1).Select(_ => Unit.Default),
236+
field.WhenAnyValue(x => x.Boolean).Skip(1).Select(_ => Unit.Default),
237+
field.WhenAnyValue(x => x.SelectedOption).Skip(1).Select(_ => Unit.Default));
238+
}
239+
240+
private static void RestoreFieldValue(ToolField field, IReadOnlyDictionary<string, string> savedFieldValues)
241+
{
242+
if (!savedFieldValues.TryGetValue(field.Id, out var value))
243+
{
244+
return;
245+
}
246+
247+
switch (field.Kind)
248+
{
249+
case ToolFieldKind.Text:
250+
case ToolFieldKind.MultiLine:
251+
case ToolFieldKind.File:
252+
case ToolFieldKind.SaveFile:
253+
field.Text = value;
254+
break;
255+
case ToolFieldKind.Number:
256+
if (decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var number))
257+
{
258+
field.Number = number;
259+
}
260+
break;
261+
case ToolFieldKind.Boolean:
262+
if (bool.TryParse(value, out var boolean))
263+
{
264+
field.Boolean = boolean;
265+
}
266+
break;
267+
case ToolFieldKind.Select:
268+
field.SelectedOption = field.Options.FirstOrDefault(option =>
269+
string.Equals(option.Value, value, StringComparison.OrdinalIgnoreCase));
270+
break;
271+
}
272+
}
273+
274+
private void SaveCurrentFieldValues()
275+
{
276+
if (_currentTool == null || Fields.Count == 0)
277+
{
278+
return;
279+
}
280+
281+
var values = Fields.ToDictionary(
282+
field => field.Id,
283+
GetFieldValue,
284+
StringComparer.OrdinalIgnoreCase);
285+
_userProfileService.SaveToolFieldValues(_currentTool.Id, values);
286+
}
287+
288+
private static string GetFieldValue(ToolField field)
289+
{
290+
return field.Kind switch
291+
{
292+
ToolFieldKind.Text or ToolFieldKind.MultiLine or ToolFieldKind.File or ToolFieldKind.SaveFile => field.Text,
293+
ToolFieldKind.Number => field.Number.ToString(CultureInfo.InvariantCulture),
294+
ToolFieldKind.Boolean => field.Boolean.ToString(),
295+
ToolFieldKind.Select => field.SelectedOption?.Value ?? string.Empty,
296+
_ => string.Empty
297+
};
298+
}
299+
224300
private static string Translate(string key)
225301
{
226302
return I18nManager.Instance.GetResource(key) ?? key;

src/CodeWF.Toolbox/App.axaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ protected override void RegisterTypes(IContainerRegistry containerRegistry)
147147
containerRegistry.RegisterSingleton<IToolMenuService, ToolMenuService>();
148148
containerRegistry.RegisterSingleton<IFileChooserService, FileChooserService>();
149149
containerRegistry.RegisterSingleton<INotificationService, NotificationService>();
150+
containerRegistry.RegisterSingleton<IUserProfileService, UserProfileService>();
150151
containerRegistry.RegisterSingleton<ILoginService, LoginService>();
151152
containerRegistry.RegisterSingleton<TitleBarSettingsViewModel>();
152153

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using CodeWF.EventBus;
2+
3+
namespace CodeWF.Toolbox.Commands;
4+
5+
public sealed class OpenToolMenuCommand : Command
6+
{
7+
public OpenToolMenuCommand(string viewName)
8+
{
9+
ViewName = viewName;
10+
}
11+
12+
public string ViewName { get; }
13+
}

src/CodeWF.Toolbox/I18n/Language.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public static class DashboardView
3232
public static readonly string ToolCountLabel = "Localization.DashboardView.ToolCountLabel";
3333
public static readonly string ModuleCountLabel = "Localization.DashboardView.ModuleCountLabel";
3434
public static readonly string PlatformLabel = "Localization.DashboardView.PlatformLabel";
35+
public static readonly string FrequentToolsTitle = "Localization.DashboardView.FrequentToolsTitle";
3536
public static readonly string CommunityTitle = "Localization.DashboardView.CommunityTitle";
3637
public static readonly string CommunityDescription = "Localization.DashboardView.CommunityDescription";
3738
public static readonly string WeChat = "Localization.DashboardView.WeChat";
@@ -109,9 +110,19 @@ public static class LoginWindow
109110
public static readonly string Title = "Localization.LoginWindow.Title";
110111
public static readonly string UsernameWatermark = "Localization.LoginWindow.UsernameWatermark";
111112
public static readonly string PasswordWatermark = "Localization.LoginWindow.PasswordWatermark";
113+
public static readonly string ConfirmPasswordWatermark = "Localization.LoginWindow.ConfirmPasswordWatermark";
112114
public static readonly string LoginButton = "Localization.LoginWindow.LoginButton";
115+
public static readonly string RegisterButton = "Localization.LoginWindow.RegisterButton";
116+
public static readonly string SwitchToRegister = "Localization.LoginWindow.SwitchToRegister";
117+
public static readonly string SwitchToLogin = "Localization.LoginWindow.SwitchToLogin";
113118
public static readonly string LocalLoginReady = "Localization.LoginWindow.LocalLoginReady";
114119
public static readonly string MissingCredentials = "Localization.LoginWindow.MissingCredentials";
120+
public static readonly string RegisterHint = "Localization.LoginWindow.RegisterHint";
121+
public static readonly string RegisterSuccess = "Localization.LoginWindow.RegisterSuccess";
122+
public static readonly string UserAlreadyExists = "Localization.LoginWindow.UserAlreadyExists";
123+
public static readonly string InvalidRegistration = "Localization.LoginWindow.InvalidRegistration";
124+
public static readonly string PasswordMismatch = "Localization.LoginWindow.PasswordMismatch";
125+
public static readonly string AccountAlreadyRunning = "Localization.LoginWindow.AccountAlreadyRunning";
115126
public static readonly string LoginSuccess = "Localization.LoginWindow.LoginSuccess";
116127
public static readonly string LoginFailed = "Localization.LoginWindow.LoginFailed";
117128
}

src/CodeWF.Toolbox/I18n/en-US.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"ToolCountLabel": "Available tools",
2222
"ModuleCountLabel": "Feature modules",
2323
"PlatformLabel": "Current platform",
24+
"FrequentToolsTitle": "Frequent tools",
2425
"CommunityTitle": "Follow and feedback",
2526
"CommunityDescription": "Scan the QR codes for toolbox updates, Avalonia articles, module plans, and feedback to the author.",
2627
"WeChat": "Contact author",
@@ -80,9 +81,19 @@
8081
"Title": "Login",
8182
"UsernameWatermark": "Username",
8283
"PasswordWatermark": "Password",
84+
"ConfirmPasswordWatermark": "Confirm password",
8385
"LoginButton": "Login",
86+
"RegisterButton": "Register",
87+
"SwitchToRegister": "Create account",
88+
"SwitchToLogin": "Back to login",
8489
"LocalLoginReady": "Local account is ready",
8590
"MissingCredentials": "Set the App.config username and password hash first",
91+
"RegisterHint": "Create a local account for this toolbox",
92+
"RegisterSuccess": "Account created",
93+
"UserAlreadyExists": "This account already exists",
94+
"InvalidRegistration": "Use 2-64 letters, digits, dots, dashes, underscores or @",
95+
"PasswordMismatch": "Passwords do not match",
96+
"AccountAlreadyRunning": "This account is already running in another instance",
8697
"LoginSuccess": "Login successful",
8798
"LoginFailed": "Invalid username or password"
8899
}

src/CodeWF.Toolbox/I18n/ja-JP.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"ToolCountLabel": "利用可能なツール",
2222
"ModuleCountLabel": "機能モジュール",
2323
"PlatformLabel": "現在のプラットフォーム",
24+
"FrequentToolsTitle": "よく使うツール",
2425
"CommunityTitle": "フォローとフィードバック",
2526
"CommunityDescription": "QR コードからツール更新、Avalonia 記事、モジュール計画を確認し、作者へフィードバックできます。",
2627
"WeChat": "作者に連絡",
@@ -80,9 +81,19 @@
8081
"Title": "ログイン",
8182
"UsernameWatermark": "ユーザー名",
8283
"PasswordWatermark": "パスワード",
84+
"ConfirmPasswordWatermark": "パスワード確認",
8385
"LoginButton": "ログイン",
86+
"RegisterButton": "登録",
87+
"SwitchToRegister": "アカウント作成",
88+
"SwitchToLogin": "ログインへ戻る",
8489
"LocalLoginReady": "ローカルアカウントを使用できます",
8590
"MissingCredentials": "先に App.config でユーザー名とパスワードハッシュを設定してください",
91+
"RegisterHint": "このツールボックス用のローカルアカウントを作成します",
92+
"RegisterSuccess": "アカウントを作成しました",
93+
"UserAlreadyExists": "このアカウントは既に存在します",
94+
"InvalidRegistration": "ユーザー名は 2-64 文字の英数字、ドット、ハイフン、下線、@ が使えます",
95+
"PasswordMismatch": "パスワードが一致しません",
96+
"AccountAlreadyRunning": "このアカウントは別のインスタンスで実行中です",
8697
"LoginSuccess": "ログイン成功",
8798
"LoginFailed": "ユーザー名またはパスワードが正しくありません"
8899
}

0 commit comments

Comments
 (0)