refactor user infrastructure

This commit is contained in:
DismissedLight
2022-08-01 13:46:50 +08:00
parent 41d99c227c
commit acb59488fb
48 changed files with 660 additions and 492 deletions

View File

@@ -35,13 +35,14 @@ public class CheckBoxWithDescriptionControl : CheckBox
private void CheckBoxSubTextControl_Loaded(object sender, RoutedEventArgs e)
{
StackPanel panel = new StackPanel() { Orientation = Orientation.Vertical };
StackPanel panel = new() { Orientation = Orientation.Vertical };
// Add text box only if the description is not empty. Required for additional plugin options.
if (!string.IsNullOrWhiteSpace(Description))
{
panel.Children.Add(new TextBlock() { Margin = new Thickness(0, 10, 0, 0), Text = Header });
panel.Children.Add(new IsEnabledTextBlock() { Style = (Style)Application.Current.Resources["SecondaryIsEnabledTextBlockStyle"], Text = Description });
//Style = (Style)Application.Current.Resources["SecondaryIsEnabledTextBlockStyle"]
panel.Children.Add(new IsEnabledTextBlock() { Text = Description });
}
else
{

View File

@@ -7,9 +7,9 @@
<Style x:Key="SettingButtonStyle" TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}" >
<Setter Property="BorderBrush" Value="{ThemeResource CardBorderBrush}" />
<Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" />
<Setter Property="Padding" Value="16,0,16,0" />
<Setter Property="Padding" Value="16,4,16,4" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
</Style>
<Style x:Key="HyperlinkButtonStyle" TargetType="HyperlinkButton" >

View File

@@ -28,8 +28,4 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<Folder Include="CoreEnvironment\" />
</ItemGroup>
</Project>

View File

@@ -28,6 +28,7 @@ public partial class App : Application
/// </summary>
public App()
{
AppInstance.GetCurrent().Activated += OnActivated;
// load app resource
InitializeComponent();
InitializeDependencyInjection();
@@ -52,16 +53,24 @@ public partial class App : Application
}
/// <summary>
/// <inheritdoc cref="Windows.Storage.ApplicationData.Current"/>
/// <inheritdoc cref="ApplicationData.Current.TemporaryFolder"/>
/// </summary>
[SuppressMessage("", "CA1822")]
public StorageFolder CacheFolder
public static StorageFolder CacheFolder
{
get => ApplicationData.Current.TemporaryFolder;
}
/// <summary>
/// <inheritdoc cref="ApplicationData.Current.LocalSettings"/>
/// </summary>
public static ApplicationDataContainer Settings
{
get => ApplicationData.Current.LocalSettings;
}
/// <summary>
/// Invoked when the application is launched.
/// Any async operation in this method should be wrapped with try catch
/// </summary>
/// <param name="args">Details about the launch request and process.</param>
[SuppressMessage("", "VSTHRD100")]
@@ -69,7 +78,6 @@ public partial class App : Application
{
AppActivationArguments activatedEventArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
AppInstance firstInstance = AppInstance.FindOrRegisterForKey("main");
firstInstance.Activated += OnActivated;
if (!firstInstance.IsCurrent)
{
@@ -83,7 +91,6 @@ public partial class App : Application
Window.Activate();
logger.LogInformation(EventIds.CommonLog, "Cache folder : {folder}", CacheFolder.Path);
logger.LogInformation(EventIds.CommonLog, "Data folder : {folder}", CacheFolder.Path);
Ioc.Default
.GetRequiredService<IMetadataService>()
@@ -117,19 +124,22 @@ public partial class App : Application
Ioc.Default.ConfigureServices(services);
}
private void AppUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
{
logger.LogError(EventIds.UnhandledException, e.Exception, "未经处理的异常");
}
private void OnActivated(object? sender, AppActivationArguments args)
{
if (args.TryGetProtocolActivatedUri(out Uri? uri))
if (args.Kind == ExtendedActivationKind.Protocol)
{
Ioc.Default.GetRequiredService<IInfoBarService>().Information(uri.ToString());
if (args.TryGetProtocolActivatedUri(out Uri? uri))
{
Ioc.Default.GetRequiredService<IInfoBarService>().Information(uri.ToString());
}
}
}
private void AppUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
{
logger.LogError(EventIds.UnhandledException, e.Exception, "未经处理的异常: [HResult:{code}]", e.Exception.HResult);
}
private void XamlBindingFailed(object sender, BindingFailedEventArgs e)
{
logger.LogCritical(EventIds.XamlBindingError, "XAML绑定失败: {message}", e.Message);

View File

@@ -5,7 +5,9 @@ using CommunityToolkit.WinUI.UI.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Snap.Hutao.Core.Caching;
using Snap.Hutao.Core.Exception;
using Snap.Hutao.Extension;
using System.Runtime.InteropServices;
using Windows.Storage;
namespace Snap.Hutao.Control.Image;
@@ -25,30 +27,34 @@ public class CachedImage : ImageEx
}
/// <inheritdoc/>
protected override async Task<ImageSource> ProvideCachedResourceAsync(Uri imageUri, CancellationToken token)
protected override async Task<ImageSource?> ProvideCachedResourceAsync(Uri imageUri, CancellationToken token)
{
IImageCache imageCache = Ioc.Default.GetRequiredService<IImageCache>();
try
{
Verify.Operation(imageUri.Host != string.Empty, "可能是空绑定产生的 [ms-appx:///]");
Verify.Operation(imageUri.Host != string.Empty, "无效的Uri");
StorageFile file = await imageCache.GetFileFromCacheAsync(imageUri);
// check token state to determine whether the operation should be canceled.
Must.TryThrowOnCanceled(token, "Image source has changed.");
Must.ThrowOnCanceled(token, "Image source has changed.");
// return a BitmapImage initialize with a uri will increase image quality.
// BitmapImage initialize with a uri will increase image quality.
return new BitmapImage(new(file.Path));
}
catch (COMException ex) when (ex.Is(COMError.WINCODEC_ERR_COMPONENTNOTFOUND))
{
// The image is corrupted, remove it.
await imageCache.RemoveAsync(imageUri.Enumerate()).ConfigureAwait(false);
return null;
}
catch (TaskCanceledException)
{
// task was explicitly canceled
throw;
return null;
}
catch
{
// maybe the image is corrupted, remove it.
await imageCache.RemoveAsync(imageUri.Enumerate()).ConfigureAwait(false);
throw;
}
}

View File

@@ -8,6 +8,7 @@ using Microsoft.UI.Xaml.Hosting;
using Microsoft.UI.Xaml.Media;
using Snap.Hutao.Core;
using Snap.Hutao.Core.Caching;
using Snap.Hutao.Core.Exception;
using Snap.Hutao.Core.Threading;
using Snap.Hutao.Extension;
using Snap.Hutao.Service.Abstraction;
@@ -72,9 +73,8 @@ public abstract class CompositionImage : Microsoft.UI.Xaml.Controls.Control
return LoadedImageSurface.StartLoadFromStream(imageStream);
}
}
catch (COMException ex) when (ex.HResult == unchecked((int)0x88982F50))
catch (COMException ex) when (ex.Is(COMError.WINCODEC_ERR_COMPONENTNOTFOUND))
{
// COMException (0x88982F50): 无法找不到组件。
return null;
}
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Core;
/// <summary>
/// 封装了打开浏览器的方法
/// </summary>
public static class Browser
{
/// <summary>
/// 打开浏览器
/// </summary>
/// <param name="url">链接</param>
/// <param name="failAction">失败时执行的回调</param>
public static void Open(string url, Action<Exception>? failAction = null)
{
try
{
ProcessHelper.Start(url);
}
catch (Exception ex)
{
failAction?.Invoke(ex);
}
}
}

View File

@@ -216,7 +216,7 @@ public abstract class CacheBase<T>
using (await cacheFolderSemaphore.EnterAsync().ConfigureAwait(false))
{
baseFolder ??= App.Current.CacheFolder;
baseFolder ??= App.CacheFolder;
if (string.IsNullOrWhiteSpace(cacheFolderName))
{

View File

@@ -11,7 +11,7 @@ namespace Snap.Hutao.Core;
/// <summary>
/// 核心环境参数
/// </summary>
internal static partial class CoreEnvironment
internal static class CoreEnvironment
{
/// <summary>
/// 当前版本
@@ -42,9 +42,4 @@ internal static partial class CoreEnvironment
object? machineGuid = Registry.GetValue(CryptographyKey, MachineGuidValue, userName);
return Md5Convert.ToHexString($"{userName}{machineGuid}");
}
}
internal static partial class CoreEnvironment
{
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Core.Exception;
/// <summary>
/// Error codes used by COM-based APIs.
/// </summary>
public enum COMError : uint
{
/// <summary>
/// The component cannot be found.
/// </summary>
WINCODEC_ERR_COMPONENTNOTFOUND = 0x88982F50,
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using System.Runtime.InteropServices;
namespace Snap.Hutao.Core.Exception;
/// <summary>
/// COM异常扩展
/// </summary>
internal static class COMExceptionExtensions
{
/// <summary>
/// 比较COM异常是否与某个错误代码等价
/// </summary>
/// <param name="exception">异常</param>
/// <param name="code">错误代码</param>
/// <returns>是否为该错误</returns>
public static bool Is(this COMException exception, COMError code)
{
return exception.HResult == unchecked((int)code);
}
}

View File

@@ -41,7 +41,7 @@ internal sealed partial class DatebaseLogger : ILogger
}
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, System.Exception? exception, Func<TState, System.Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
{

View File

@@ -14,13 +14,12 @@ internal static class LocalSetting
/// <summary>
/// 由于 <see cref="Windows.Foundation.Collections.IPropertySet"/> 没有 nullable context,
/// 在处理引用类型时需要格外小心
/// 将值类型的操作与引用类型区分开,可以提升一定的性能
/// </summary>
private static readonly ApplicationDataContainer Container;
static LocalSetting()
{
Container = ApplicationData.Current.LocalSettings;
Container = App.Settings;
}
/// <summary>
@@ -79,17 +78,11 @@ internal static class LocalSetting
/// <typeparam name="T">设置项的类型</typeparam>
/// <param name="key">键</param>
/// <param name="value">值</param>
public static void Set<T>(string key, T? value)
where T : class
/// <returns>设置的值</returns>
public static T Set<T>(string key, T value)
{
try
{
Container.Values[key] = value;
}
catch (Exception ex)
{
Debug.WriteLine(ex);
}
Container.Values[key] = value;
return value;
}
/// <summary>
@@ -105,7 +98,7 @@ internal static class LocalSetting
{
Container.Values[key] = value;
}
catch (Exception ex)
catch (System.Exception ex)
{
Debug.WriteLine(ex);
}

View File

@@ -1,8 +1,6 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using System.Diagnostics;
using System.Runtime;
using System.Runtime.CompilerServices;
namespace Snap.Hutao.Core.Validation;
@@ -20,42 +18,20 @@ public static class Must
/// <param name="parameterName">The name of the parameter to include in any thrown exception.</param>
/// <returns>The value of the parameter.</returns>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="value"/> is <c>null</c>.</exception>
[DebuggerStepThrough]
[TargetedPatchingOptOut("Performance critical to inline across NGen image boundaries")]
public static T NotNull<T>([NotNull] T value, [CallerArgumentExpression("value")] string? parameterName = null)
where T : class // ensures value-types aren't passed to a null checking method
{
if (value is null)
{
throw new ArgumentNullException(parameterName);
}
return value;
return value ?? throw new ArgumentNullException(parameterName);
}
/// <summary>
/// Unconditionally throws an <see cref="NotSupportedException"/>.
/// </summary>
/// <returns>Nothing. This method always throws.</returns>
[DebuggerStepThrough]
[DoesNotReturn]
public static Exception NeverHappen()
public static System.Exception NeverHappen()
{
// Keep these two as separate lines of code, so the debugger can come in during the assert dialog
// that the exception's constructor displays, and the debugger can then be made to skip the throw
// in order to continue the investigation.
NotSupportedException exception = new("该行为不应发生,请联系开发者进一步确认");
bool proceed = true; // allows debuggers to skip the throw statement
if (proceed)
{
throw exception;
}
else
{
#pragma warning disable CS8763
return new Exception();
#pragma warning restore CS8763
}
throw new NotSupportedException("该行为不应发生,请联系开发者进一步确认");
}
/// <summary>
@@ -63,26 +39,11 @@ public static class Must
/// </summary>
/// <typeparam name="T">The type that the method should be typed to return (although it never returns anything).</typeparam>
/// <returns>Nothing. This method always throws.</returns>
[DebuggerStepThrough]
[DoesNotReturn]
[return: MaybeNull]
public static T NeverHappen<T>()
{
// Keep these two as separate lines of code, so the debugger can come in during the assert dialog
// that the exception's constructor displays, and the debugger can then be made to skip the throw
// in order to continue the investigation.
NotSupportedException exception = new("该行为不应发生,请联系开发者进一步确认");
bool proceed = true; // allows debuggers to skip the throw statement
if (proceed)
{
throw exception;
}
else
{
#pragma warning disable CS8763 // 不应返回标记为 [DoesNotReturn] 的方法。
return default;
#pragma warning restore CS8763 // 不应返回标记为 [DoesNotReturn] 的方法。
}
throw new NotSupportedException("该行为不应发生,请联系开发者进一步确认");
}
/// <summary>
@@ -91,9 +52,8 @@ public static class Must
/// <param name="token">取消令牌</param>
/// <param name="message">取消消息</param>
/// <exception cref="TaskCanceledException">任务被取消</exception>
[DebuggerStepThrough]
[SuppressMessage("", "CA1068")]
public static void TryThrowOnCanceled(CancellationToken token, string message)
public static void ThrowOnCanceled(CancellationToken token, string message)
{
if (token.IsCancellationRequested)
{

View File

@@ -3,6 +3,7 @@
using Microsoft.Web.WebView2.Core;
using Snap.Hutao.Core.Logging;
using System.IO;
namespace Snap.Hutao.Core;
@@ -35,7 +36,7 @@ internal abstract class WebView2Helper
{
version = CoreWebView2Environment.GetAvailableBrowserVersionString();
}
catch (Exception ex)
catch (FileNotFoundException ex)
{
ILogger<WebView2Helper> logger = Ioc.Default.GetRequiredService<ILogger<WebView2Helper>>();
logger.LogError(EventIds.WebView2EnvironmentException, ex, "WebView2 运行时未安装");

View File

@@ -0,0 +1,82 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.UI.Xaml;
using Snap.Hutao.Control.HostBackdrop;
using Snap.Hutao.Core.Logging;
using Snap.Hutao.Core.Setting;
using Snap.Hutao.Core.Win32;
using WinRT.Interop;
namespace Snap.Hutao.Core;
/// <summary>
/// 窗口状态管理器
/// </summary>
internal class WindowManager
{
private readonly IntPtr handle;
private readonly Window window;
private readonly UIElement titleBar;
private readonly ILogger<WindowManager> logger;
/// <summary>
/// 构造一个新的窗口状态管理器
/// </summary>
/// <param name="window">窗口</param>
/// <param name="titleBar">充当标题栏的元素</param>
public WindowManager(Window window, UIElement titleBar)
{
this.window = window;
this.titleBar = titleBar;
logger = Ioc.Default.GetRequiredService<ILogger<WindowManager>>();
handle = WindowNative.GetWindowHandle(window);
InitializeWindow();
}
private static RECT RetriveWindowRect()
{
int left = LocalSetting.GetValueType<int>(SettingKeys.WindowLeft);
int top = LocalSetting.GetValueType<int>(SettingKeys.WindowTop);
int right = LocalSetting.GetValueType<int>(SettingKeys.WindowRight);
int bottom = LocalSetting.GetValueType<int>(SettingKeys.WindowBottom);
return new RECT(left, top, right, bottom);
}
private static void SaveWindowRect(IntPtr handle)
{
WINDOWPLACEMENT windowPlacement = WINDOWPLACEMENT.Default;
User32.GetWindowPlacement(handle, ref windowPlacement);
LocalSetting.Set(SettingKeys.WindowLeft, windowPlacement.NormalPosition.Left);
LocalSetting.Set(SettingKeys.WindowTop, windowPlacement.NormalPosition.Top);
LocalSetting.Set(SettingKeys.WindowRight, windowPlacement.NormalPosition.Right);
LocalSetting.Set(SettingKeys.WindowBottom, windowPlacement.NormalPosition.Bottom);
}
private void InitializeWindow()
{
window.ExtendsContentIntoTitleBar = true;
window.SetTitleBar(titleBar);
window.Closed += OnWindowClosed;
User32.SetWindowText(handle, "胡桃");
RECT rect = RetriveWindowRect();
if (rect.Area > 0)
{
WINDOWPLACEMENT windowPlacement = WINDOWPLACEMENT.Create(new POINT(-1, -1), rect, ShowWindowCommand.Normal);
User32.SetWindowPlacement(handle, ref windowPlacement);
}
bool micaApplied = new SystemBackdrop(window).TrySetBackdrop();
logger.LogInformation(EventIds.BackdropState, "Apply {name} : {result}", nameof(SystemBackdrop), micaApplied ? "succeed" : "failed");
}
private void OnWindowClosed(object sender, WindowEventArgs args)
{
SaveWindowRect(handle);
}
}

View File

@@ -20,9 +20,8 @@ public static class AppActivationArgumentsExtensions
public static bool TryGetProtocolActivatedUri(this AppActivationArguments activatedEventArgs, [NotNullWhen(true)] out Uri? uri)
{
uri = null;
if (activatedEventArgs.Kind == ExtendedActivationKind.Protocol)
if (activatedEventArgs.Data is IProtocolActivatedEventArgs protocolArgs)
{
IProtocolActivatedEventArgs protocolArgs = (activatedEventArgs.Data as IProtocolActivatedEventArgs)!;
uri = protocolArgs.Uri;
return true;
}

View File

@@ -19,5 +19,4 @@ public static class PackageVersionExtensions
{
return new Version(packageVersion.Major, packageVersion.Minor, packageVersion.Build, packageVersion.Revision);
}
}
}

View File

@@ -19,7 +19,7 @@ namespace Snap.Hutao;
/// </summary>
internal static class IocHttpClientConfiguration
{
private const string CommonUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Snap Hutao";
private static readonly string CommonUA = $"Snap Hutao/{Core.CoreEnvironment.Version}";
/// <summary>
/// 添加 <see cref="HttpClient"/>
@@ -30,7 +30,8 @@ internal static class IocHttpClientConfiguration
{
// services
services.AddHttpClient<MetadataService>(DefaultConfiguration);
services.AddHttpClient<ImageCache>(DefaultConfiguration).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() { MaxConnectionsPerServer = 20 });
services.AddHttpClient<ImageCache>(DefaultConfiguration)
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() { MaxConnectionsPerServer = 20 });
// normal clients
services.AddHttpClient<AnnouncementClient>(DefaultConfiguration);

View File

@@ -3,11 +3,7 @@
using Microsoft.UI.Xaml;
using Snap.Hutao.Context.Database;
using Snap.Hutao.Control.HostBackdrop;
using Snap.Hutao.Core.Logging;
using Snap.Hutao.Core.Setting;
using Snap.Hutao.Core.Win32;
using WinRT.Interop;
using Snap.Hutao.Core;
namespace Snap.Hutao;
@@ -18,8 +14,6 @@ namespace Snap.Hutao;
public sealed partial class MainWindow : Window
{
private readonly AppDbContext appDbContext;
private readonly ILogger<MainWindow> logger;
private readonly IntPtr handle;
/// <summary>
/// 构造一个新的主窗体
@@ -29,56 +23,13 @@ public sealed partial class MainWindow : Window
public MainWindow(AppDbContext appDbContext, ILogger<MainWindow> logger)
{
this.appDbContext = appDbContext;
this.logger = logger;
InitializeComponent();
handle = WindowNative.GetWindowHandle(this);
InitializeWindow();
}
private static RECT RetriveWindowRect()
{
int left = LocalSetting.GetValueType<int>(SettingKeys.WindowLeft);
int top = LocalSetting.GetValueType<int>(SettingKeys.WindowTop);
int right = LocalSetting.GetValueType<int>(SettingKeys.WindowRight);
int bottom = LocalSetting.GetValueType<int>(SettingKeys.WindowBottom);
return new RECT(left, top, right, bottom);
}
private static void SaveWindowRect(IntPtr handle)
{
WINDOWPLACEMENT windowPlacement = WINDOWPLACEMENT.Default;
User32.GetWindowPlacement(handle, ref windowPlacement);
LocalSetting.SetValueType(SettingKeys.WindowLeft, windowPlacement.NormalPosition.Left);
LocalSetting.SetValueType(SettingKeys.WindowTop, windowPlacement.NormalPosition.Top);
LocalSetting.SetValueType(SettingKeys.WindowRight, windowPlacement.NormalPosition.Right);
LocalSetting.SetValueType(SettingKeys.WindowBottom, windowPlacement.NormalPosition.Bottom);
}
private void InitializeWindow()
{
ExtendsContentIntoTitleBar = true;
SetTitleBar(TitleBarView.DragableArea);
User32.SetWindowText(handle, "胡桃");
RECT rect = RetriveWindowRect();
if (rect.Area > 0)
{
WINDOWPLACEMENT windowPlacement = WINDOWPLACEMENT.Create(new POINT(-1, -1), rect, ShowWindowCommand.Normal);
User32.SetWindowPlacement(handle, ref windowPlacement);
}
bool micaApplied = new SystemBackdrop(this).TrySetBackdrop();
logger.LogInformation(EventIds.BackdropState, "Apply {name} : {result}", nameof(SystemBackdrop), micaApplied ? "succeed" : "failed");
_ = new WindowManager(this, TitleBarView.DragableArea);
}
private void MainWindowClosed(object sender, WindowEventArgs args)
{
SaveWindowRect(handle);
// save datebase
// save userdata datebase
int changes = appDbContext.SaveChanges();
Verify.Operation(changes == 0, "存在未经处理的数据库记录更改");
}

View File

@@ -0,0 +1,110 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Extension;
using Snap.Hutao.Web.Hoyolab.Bbs.User;
using Snap.Hutao.Web.Hoyolab.Takumi.Binding;
using System.Collections.Generic;
using System.Linq;
namespace Snap.Hutao.Model.Binding;
/// <summary>
/// 用于视图绑定的用户
/// </summary>
public class User : Observable
{
private readonly Entity.User inner;
private UserGameRole? selectedUserGameRole;
private bool isInitialized = false;
/// <summary>
/// 构造一个新的绑定视图用户
/// </summary>
/// <param name="user">用户实体</param>
private User(Entity.User user)
{
inner = user;
}
/// <summary>
/// 用户信息
/// </summary>
public UserInfo? UserInfo { get; private set; }
/// <summary>
/// 用户信息
/// </summary>
public List<UserGameRole> UserGameRoles { get; private set; } = default!;
/// <summary>
/// 用户信息
/// </summary>
public UserGameRole? SelectedUserGameRole
{
get => selectedUserGameRole;
private set => Set(ref selectedUserGameRole, value);
}
/// <inheritdoc cref="Entity.User.IsSelected"/>
public bool IsSelected
{
get => inner.IsSelected;
set => inner.IsSelected = value;
}
/// <inheritdoc cref="Entity.User.Cookie"/>
public string? Cookie
{
get => inner.Cookie;
set => inner.Cookie = value;
}
/// <summary>
/// 内部的用户实体
/// </summary>
public Entity.User Entity { get => inner; }
/// <summary>
/// 是否初始化完成
/// </summary>
public bool IsInitialized { get => isInitialized; }
/// <summary>
/// 初始化用户
/// </summary>
/// <param name="inner">用户实体</param>
/// <param name="userClient">用户客户端</param>
/// <param name="userGameRoleClient">角色客户端</param>
/// <param name="token">取消令牌</param>
/// <returns>用户是否初始化完成若Cookie失效会返回 <see langword="false"/> </returns>
internal static async Task<User?> CreateAsync(Entity.User inner, UserClient userClient, UserGameRoleClient userGameRoleClient, CancellationToken token = default)
{
User user = new(inner);
bool successful = await user.InitializeAsync(userClient, userGameRoleClient, token).ConfigureAwait(false);
return successful ? user : null;
}
private async Task<bool> InitializeAsync(UserClient userClient, UserGameRoleClient userGameRoleClient, CancellationToken token = default)
{
if (isInitialized)
{
return true;
}
UserInfo = await userClient
.GetUserFullInfoAsync(this, token)
.ConfigureAwait(false);
UserGameRoles = await userGameRoleClient
.GetUserGameRolesAsync(this, token)
.ConfigureAwait(false);
SelectedUserGameRole = UserGameRoles.FirstOrFirstOrDefault(role => role.IsChosen);
isInitialized = true;
return UserInfo != null && UserGameRoles.Any();
}
}

View File

@@ -1,16 +1,8 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using CommunityToolkit.Mvvm.Input;
using Snap.Hutao.Extension;
using Snap.Hutao.Service.Abstraction;
using Snap.Hutao.Web.Hoyolab.Bbs.User;
using Snap.Hutao.Web.Hoyolab.Takumi.Binding;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using Windows.ApplicationModel.DataTransfer;
namespace Snap.Hutao.Model.Entity;
@@ -18,15 +10,8 @@ namespace Snap.Hutao.Model.Entity;
/// 用户
/// </summary>
[Table("users")]
public class User : Observable
public class User
{
/// <summary>
/// 无用户
/// </summary>
public static readonly User None = new();
private bool isInitialized = false;
private UserGameRole? selectedUserGameRole;
/// <summary>
/// 内部Id
/// </summary>
@@ -44,44 +29,6 @@ public class User : Observable
/// </summary>
public string? Cookie { get; set; }
/// <summary>
/// 用户信息
/// </summary>
[NotMapped]
public UserInfo? UserInfo { get; private set; }
/// <summary>
/// 用户信息
/// </summary>
[NotMapped]
public List<UserGameRole>? UserGameRoles { get; private set; }
/// <summary>
/// 用户信息
/// </summary>
[NotMapped]
public UserGameRole? SelectedUserGameRole
{
get => selectedUserGameRole;
private set => Set(ref selectedUserGameRole, value);
}
/// <summary>
/// 复制Cookie命令
/// </summary>
[NotMapped]
public ICommand? CopyCookieCommand { get; set; }
/// <summary>
/// 判断用户是否为空用户
/// </summary>
/// <param name="user">待检测的用户</param>
/// <returns>是否为空用户</returns>
public static bool IsNone([NotNullWhen(false)] User? user)
{
return ReferenceEquals(NoneIfNullOrNoCookie(user), None);
}
/// <summary>
/// 设置用户的选中状态
/// 同时更新用户选择的角色信息
@@ -90,83 +37,6 @@ public class User : Observable
/// <param name="isSelected">是否选中</param>
public static void SetSelectionState(User user, bool isSelected)
{
Verify.Operation(!IsNone(user), "尝试设置一个空的用户");
user!.IsSelected = isSelected;
if (isSelected)
{
user.SelectedUserGameRole ??= user.UserGameRoles!.FirstOrFirstOrDefault(role => role.IsChosen);
}
}
/// <summary>
/// 初始化此用户
/// 初始化前必须设置 <see cref="RemoveCommand"/> 属性
/// </summary>
/// <param name="userClient">用户客户端</param>
/// <param name="userGameRoleClient">角色客户端</param>
/// <param name="token">取消令牌</param>
/// <returns>用户是否初始化完成若Cookie失效会返回 <see langword="false"/> </returns>
internal async Task<bool> InitializeAsync(UserClient userClient, UserGameRoleClient userGameRoleClient, CancellationToken token = default)
{
if (IsNone(this))
{
return false;
}
if (isInitialized)
{
return true;
}
CopyCookieCommand = new RelayCommand(CopyCookie);
UserInfo = await userClient
.GetUserFullInfoAsync(this, token)
.ConfigureAwait(false);
UserGameRoles = await userGameRoleClient
.GetUserGameRolesAsync(this, token)
.ConfigureAwait(false);
SelectedUserGameRole = UserGameRoles.FirstOrFirstOrDefault(role => role.IsChosen);
isInitialized = true;
return UserInfo != null && UserGameRoles.Any();
}
/// <summary>
/// 尝试尽可能转换为 <see cref="None"/>
/// </summary>
/// <param name="user">用户</param>
/// <returns>转换后的用户</returns>
private static User NoneIfNullOrNoCookie(User? user)
{
if (user is null || user.Cookie == null)
{
return None;
}
else
{
return user;
}
}
private void CopyCookie()
{
IInfoBarService infoBarService = Ioc.Default.GetRequiredService<IInfoBarService>();
try
{
DataPackage content = new();
content.SetText(Must.NotNull(Cookie!));
Clipboard.SetContent(content);
infoBarService.Success($"{UserInfo?.Nickname} 的 Cookie 复制成功");
}
catch (Exception e)
{
infoBarService.Error(e);
}
}
}

View File

@@ -8,6 +8,11 @@ namespace Snap.Hutao.Model.Metadata.Achievement;
/// </summary>
public class AchievementGoal
{
/// <summary>
/// Id
/// </summary>
public int Id { get; set; }
/// <summary>
/// 排序顺序
/// </summary>

View File

@@ -0,0 +1,26 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.UI.Xaml.Data;
namespace Snap.Hutao.Model.Metadata.Converter;
/// <summary>
/// 角色头像转换器
/// </summary>
internal class AchievementIconConverter : IValueConverter
{
private const string BaseUrl = "https://static.snapgenshin.com/AchievementIcon/{0}.png";
/// <inheritdoc/>
public object Convert(object value, Type targetType, object parameter, string language)
{
return new Uri(string.Format(BaseUrl, value));
}
/// <inheritdoc/>
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw Must.NeverHappen();
}
}

View File

@@ -25,6 +25,12 @@ internal class AvatarNameCardPicConverter : IValueConverter
return new Uri(string.Format(BaseUrl, avatarName));
}
/// <inheritdoc/>
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw Must.NeverHappen();
}
private static string ReplaceSpecialCaseNaming(string avatarName)
{
return avatarName switch
@@ -33,10 +39,4 @@ internal class AvatarNameCardPicConverter : IValueConverter
_ => avatarName,
};
}
/// <inheritdoc/>
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw Must.NeverHappen();
}
}

View File

@@ -21,6 +21,7 @@ public class Pair<TKey, TValue>
Value = value;
}
/// <summary>
/// 键
/// </summary>
public TKey Key { get; set; }

View File

@@ -23,7 +23,7 @@
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
<Resource Language="zh-CN"/>
</Resources>
<Applications>

View File

@@ -0,0 +1,13 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Achievement;
/// <summary>
/// 成就服务
/// </summary>
[Injection(InjectAs.Transient, typeof(IAchievementService))]
internal class AchievementService : IAchievementService
{
}

View File

@@ -0,0 +1,9 @@
namespace Snap.Hutao.Service.Achievement;
/// <summary>
/// 成就服务抽象
/// </summary>
internal interface IAchievementService
{
}

View File

@@ -26,47 +26,47 @@ internal interface IMetadataService
/// </summary>
/// <param name="token">取消令牌</param>
/// <returns>成就列表</returns>
ValueTask<IEnumerable<Achievement>> GetAchievementsAsync(CancellationToken token = default);
ValueTask<List<Model.Metadata.Achievement.Achievement>> GetAchievementsAsync(CancellationToken token = default);
/// <summary>
/// 异步获取成就分类列表
/// </summary>
/// <param name="token">取消令牌</param>
/// <returns>成就分类列表</returns>
ValueTask<IEnumerable<AchievementGoal>> GetAchievementGoalsAsync(CancellationToken token = default);
ValueTask<List<AchievementGoal>> GetAchievementGoalsAsync(CancellationToken token = default);
/// <summary>
/// 异步获取角色列表
/// </summary>
/// <param name="token">取消令牌</param>
/// <returns>角色列表</returns>
ValueTask<IEnumerable<Avatar>> GetAvatarsAsync(CancellationToken token = default);
ValueTask<List<Avatar>> GetAvatarsAsync(CancellationToken token = default);
/// <summary>
/// 异步获取圣遗物列表
/// </summary>
/// <param name="token">取消令牌</param>
/// <returns>圣遗物列表</returns>
ValueTask<IEnumerable<Reliquary>> GetReliquariesAsync(CancellationToken token = default);
ValueTask<List<Reliquary>> GetReliquariesAsync(CancellationToken token = default);
/// <summary>
/// 异步获取圣遗物强化属性列表
/// </summary>
/// <param name="token">取消令牌</param>
/// <returns>圣遗物强化属性列表</returns>
ValueTask<IEnumerable<ReliquaryAffix>> GetReliquaryAffixesAsync(CancellationToken token = default);
ValueTask<List<ReliquaryAffix>> GetReliquaryAffixesAsync(CancellationToken token = default);
/// <summary>
/// 异步获取圣遗物主属性强化属性列表
/// </summary>
/// <param name="token">取消令牌</param>
/// <returns>圣遗物强化属性列表</returns>
ValueTask<IEnumerable<ReliquaryAffixBase>> GetReliquaryMainAffixesAsync(CancellationToken token = default);
ValueTask<List<ReliquaryAffixBase>> GetReliquaryMainAffixesAsync(CancellationToken token = default);
/// <summary>
/// 异步获取武器列表
/// </summary>
/// <param name="token">取消令牌</param>
/// <returns>武器列表</returns>
ValueTask<IEnumerable<Weapon>> GetWeaponsAsync(CancellationToken token = default);
}
ValueTask<List<Weapon>> GetWeaponsAsync(CancellationToken token = default);
}

View File

@@ -91,45 +91,45 @@ internal class MetadataService : IMetadataService, IMetadataInitializer, ISuppor
}
/// <inheritdoc/>
public ValueTask<IEnumerable<AchievementGoal>> GetAchievementGoalsAsync(CancellationToken token = default)
public ValueTask<List<AchievementGoal>> GetAchievementGoalsAsync(CancellationToken token = default)
{
return GetMetadataAsync<IEnumerable<AchievementGoal>>("AchievementGoal", token);
return GetMetadataAsync<List<AchievementGoal>>("AchievementGoal", token);
}
/// <inheritdoc/>
public ValueTask<IEnumerable<Achievement>> GetAchievementsAsync(CancellationToken token = default)
public ValueTask<List<Model.Metadata.Achievement.Achievement>> GetAchievementsAsync(CancellationToken token = default)
{
return GetMetadataAsync<IEnumerable<Achievement>>("Achievement", token);
return GetMetadataAsync<List<Model.Metadata.Achievement.Achievement>>("Achievement", token);
}
/// <inheritdoc/>
public ValueTask<IEnumerable<Avatar>> GetAvatarsAsync(CancellationToken token = default)
public ValueTask<List<Avatar>> GetAvatarsAsync(CancellationToken token = default)
{
return GetMetadataAsync<IEnumerable<Avatar>>("Avatar", token);
return GetMetadataAsync<List<Avatar>>("Avatar", token);
}
/// <inheritdoc/>
public ValueTask<IEnumerable<Reliquary>> GetReliquariesAsync(CancellationToken token = default)
public ValueTask<List<Reliquary>> GetReliquariesAsync(CancellationToken token = default)
{
return GetMetadataAsync<IEnumerable<Reliquary>>("Reliquary", token);
return GetMetadataAsync<List<Reliquary>>("Reliquary", token);
}
/// <inheritdoc/>
public ValueTask<IEnumerable<ReliquaryAffix>> GetReliquaryAffixesAsync(CancellationToken token = default)
public ValueTask<List<ReliquaryAffix>> GetReliquaryAffixesAsync(CancellationToken token = default)
{
return GetMetadataAsync<IEnumerable<ReliquaryAffix>>("ReliquaryAffix", token);
return GetMetadataAsync<List<ReliquaryAffix>>("ReliquaryAffix", token);
}
/// <inheritdoc/>
public ValueTask<IEnumerable<ReliquaryAffixBase>> GetReliquaryMainAffixesAsync(CancellationToken token = default)
public ValueTask<List<ReliquaryAffixBase>> GetReliquaryMainAffixesAsync(CancellationToken token = default)
{
return GetMetadataAsync<IEnumerable<ReliquaryAffixBase>>("ReliquaryMainAffix", token);
return GetMetadataAsync<List<ReliquaryAffixBase>>("ReliquaryMainAffix", token);
}
/// <inheritdoc/>
public ValueTask<IEnumerable<Weapon>> GetWeaponsAsync(CancellationToken token = default)
public ValueTask<List<Weapon>> GetWeaponsAsync(CancellationToken token = default)
{
return GetMetadataAsync<IEnumerable<Weapon>>("Weapon", token);
return GetMetadataAsync<List<Weapon>>("Weapon", token);
}
private async Task<bool> TryUpdateMetadataAsync(CancellationToken token = default)

View File

@@ -1,7 +1,7 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Binding;
using System.Collections.Generic;
using System.Collections.ObjectModel;
@@ -13,25 +13,24 @@ namespace Snap.Hutao.Service.Abstraction;
public interface IUserService
{
/// <summary>
/// 获取当前用户信息
/// 获取或设置当前用户
/// </summary>
User? CurrentUser { get; set; }
/// <summary>
/// 异步获取用户信息枚举
/// 每个用户信息都应准备完成
/// 异步获取同步的用户信息集合
/// 对集合的操作应通过服务抽象完成
/// 此操作不能取消
/// </summary>
/// <param name="removeCommand">移除用户命令</param>
/// <returns>准备完成的用户信息枚举</returns>
Task<ObservableCollection<User>> GetInitializedUsersAsync();
Task<ObservableCollection<User>> GetUserCollectionAsync();
/// <summary>
/// 异步添加用户
/// 通常用户是未初始化的
/// </summary>
/// <param name="user">待添加的用户</param>
/// <param name="uid">用户的米游社UID</param>
/// <param name="uid">用户的米游社UID,用于检查是否包含重复的用户</param>
/// <returns>用户初始化是否成功</returns>
Task<UserAddResult> TryAddUserAsync(User user, string uid);
@@ -48,4 +47,11 @@ public interface IUserService
/// <param name="cookie">cookie的字符串形式</param>
/// <returns>包含cookie信息的字典</returns>
IDictionary<string, string> ParseCookie(string cookie);
/// <summary>
/// 创建一个新的绑定用户
/// </summary>
/// <param name="cookie">cookie的字符串形式</param>
/// <returns>新的绑定用户</returns>
Task<User?> CreateUserAsync(string cookie);
}

View File

@@ -22,9 +22,4 @@ public enum UserAddResult
/// 已经存在该用户
/// </summary>
AlreadyExists,
/// <summary>
/// 初始化用户失败
/// </summary>
InitializeFailed,
}

View File

@@ -1,9 +1,7 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.EntityFrameworkCore;
using Snap.Hutao.Context.Database;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service.Abstraction;
using Snap.Hutao.Web.Hoyolab.Bbs.User;
using Snap.Hutao.Web.Hoyolab.Takumi.Binding;
@@ -15,16 +13,17 @@ namespace Snap.Hutao.Service;
/// <summary>
/// 用户服务
/// 主要负责将用户数据与数据库同步
/// </summary>
[Injection(InjectAs.Transient, typeof(IUserService))]
[Injection(InjectAs.Singleton, typeof(IUserService))]
internal class UserService : IUserService
{
private readonly AppDbContext appDbContext;
private readonly UserClient userClient;
private readonly UserGameRoleClient userGameRoleClient;
private User? currentUser;
private ObservableCollection<User>? cachedUsers = null;
private Model.Binding.User? currentUser;
private ObservableCollection<Model.Binding.User>? userCollection = null;
/// <summary>
/// 构造一个新的用户服务
@@ -40,18 +39,18 @@ internal class UserService : IUserService
}
/// <inheritdoc/>
public User? CurrentUser
public Model.Binding.User? CurrentUser
{
get => currentUser;
set
{
// only update when not processing a deletion
if (!User.IsNone(value))
if (value != null)
{
if (!User.IsNone(currentUser))
if (currentUser != null)
{
User.SetSelectionState(currentUser, false);
appDbContext.Users.Update(currentUser);
currentUser.IsSelected = false;
appDbContext.Users.Update(currentUser.Entity);
appDbContext.SaveChanges();
}
}
@@ -59,91 +58,98 @@ internal class UserService : IUserService
// 当删除到无用户时也能正常反应状态
currentUser = value;
if (!User.IsNone(currentUser))
if (currentUser != null)
{
User.SetSelectionState(currentUser, true);
appDbContext.Users.Update(currentUser);
currentUser.IsSelected = true;
appDbContext.Users.Update(currentUser.Entity);
appDbContext.SaveChanges();
}
}
}
/// <inheritdoc/>
public async Task<UserAddResult> TryAddUserAsync(User newUser, string uid)
public async Task<UserAddResult> TryAddUserAsync(Model.Binding.User newUser, string uid)
{
Must.NotNull(cachedUsers!);
Must.NotNull(userCollection!);
// 查找是否有相同的uid
User? userWithSameUid = cachedUsers
.SingleOrDefault(u => u.UserInfo!.Uid == uid);
if (userWithSameUid != null)
if (userCollection.SingleOrDefault(u => u.UserInfo!.Uid == uid) is Model.Binding.User userWithSameUid)
{
// Prevent users from adding same cookie.
// Prevent users from adding a completely same cookie.
if (userWithSameUid.Cookie == newUser.Cookie)
{
return UserAddResult.AlreadyExists;
}
else
{
// Try update user here.
// Update user cookie here.
userWithSameUid.Cookie = newUser.Cookie;
appDbContext.Users.Update(userWithSameUid);
await appDbContext
.SaveChangesAsync()
.ConfigureAwait(false);
appDbContext.Users.Update(userWithSameUid.Entity);
await appDbContext.SaveChangesAsync().ConfigureAwait(false);
return UserAddResult.Updated;
}
}
// must continue on the caller thread.
if (await newUser.InitializeAsync(userClient, userGameRoleClient))
else
{
appDbContext.Users.Add(newUser);
Verify.Operation(newUser.IsInitialized, "该用户尚未初始化");
await appDbContext
.SaveChangesAsync()
.ConfigureAwait(false);
// Sync database
appDbContext.Users.Add(newUser.Entity);
await appDbContext.SaveChangesAsync().ConfigureAwait(false);
// Sync cache
userCollection.Add(newUser);
return UserAddResult.Added;
}
return UserAddResult.InitializeFailed;
}
/// <inheritdoc/>
public Task RemoveUserAsync(User user)
public Task RemoveUserAsync(Model.Binding.User user)
{
appDbContext.Users.Remove(user);
userCollection!.Remove(user);
appDbContext.Users.Remove(user.Entity);
return appDbContext.SaveChangesAsync();
}
/// <inheritdoc/>
public async Task<ObservableCollection<User>> GetInitializedUsersAsync()
public async Task<ObservableCollection<Model.Binding.User>> GetUserCollectionAsync()
{
if (cachedUsers == null)
if (userCollection == null)
{
await appDbContext.Users
.LoadAsync()
.ConfigureAwait(false);
List<Model.Binding.User> users = new();
cachedUsers = appDbContext.Users.Local.ToObservableCollection();
foreach (User user in cachedUsers)
foreach (Model.Entity.User user in appDbContext.Users)
{
await user
.InitializeAsync(userClient, userGameRoleClient)
Model.Binding.User? initialized = await Model.Binding.User
.CreateAsync(user, userClient, userGameRoleClient)
.ConfigureAwait(false);
if (initialized != null)
{
users.Add(initialized);
}
else
{
// User is unable to be initialized, remove it.
appDbContext.Users.Remove(user);
await appDbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
CurrentUser = await appDbContext.Users
.SingleOrDefaultAsync(user => user.IsSelected)
.ConfigureAwait(false);
CurrentUser = users.SingleOrDefault(user => user.IsSelected);
userCollection = new(users);
}
return cachedUsers;
return userCollection;
}
/// <inheritdoc/>
public Task<Model.Binding.User?> CreateUserAsync(string cookie)
{
return Model.Binding.User.CreateAsync(new() { Cookie = cookie }, userClient, userGameRoleClient);
}
/// <inheritdoc/>

View File

@@ -31,6 +31,9 @@
<ItemsControl
x:Name="DetailsHost"
VerticalAlignment="Top">
<ItemsControl.ItemContainerTransitions>
<ContentThemeTransition/>
</ItemsControl.ItemContainerTransitions>
<ItemsControl.ItemTemplate>
<DataTemplate>
<sc:Setting Margin="0,2,0,0" Header="{Binding Description}" Padding="12,0">

View File

@@ -44,9 +44,7 @@
<Frame x:Name="ContentFrame">
<Frame.ContentTransitions>
<TransitionCollection>
<NavigationThemeTransition/>
</TransitionCollection>
<NavigationThemeTransition/>
</Frame.ContentTransitions>
</Frame>
</NavigationView>

View File

@@ -5,28 +5,116 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mxi="using:Microsoft.Xaml.Interactivity"
xmlns:sc="using:SettingsUI.Controls"
xmlns:shcb="using:Snap.Hutao.Control.Behavior"
xmlns:shcc="using:Snap.Hutao.Control.Cancellable"
xmlns:shci="using:Snap.Hutao.Control.Image"
xmlns:shmmc="using:Snap.Hutao.Model.Metadata.Converter"
xmlns:shv="using:Snap.Hutao.ViewModel"
mc:Ignorable="d"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
d:DataContext="{d:DesignInstance shv:AchievementViewModel}">
<shcc:CancellablePage.Resources>
<shmmc:AchievementIconConverter x:Key="AchievementIconConverter"/>
</shcc:CancellablePage.Resources>
<mxi:Interaction.Behaviors>
<shcb:InvokeCommandOnLoadedBehavior Command="{Binding OpenUICommand}"/>
</mxi:Interaction.Behaviors>
<Grid>
<ScrollViewer>
<ItemsControl
Margin="12,0,16,12"
ItemsSource="{Binding AchievementsView}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<sc:Setting
Margin="0,12,0,0"
Header="{Binding Title}"
Description="{Binding Description}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<SplitView
IsPaneOpen="True"
DisplayMode="Inline"
OpenPaneLength="252">
<SplitView.PaneBackground>
<SolidColorBrush Color="{ThemeResource CardBackgroundFillColorSecondary}"/>
</SplitView.PaneBackground>
<SplitView.Pane>
<ListView
SelectionMode="Single"
SelectedItem="{Binding SelectedAchievementGoal,Mode=TwoWay}"
ItemsSource="{Binding AchievementGoals}">
<ListView.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="24"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<shci:CachedImage
Width="24"
Height="24"
Grid.Column="0"
Source="{Binding Icon,Converter={StaticResource AchievementIconConverter}}"/>
<TextBlock
VerticalAlignment="Center"
Grid.Column="1"
Margin="12,0,0,2"
Text="{Binding Name}"/>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</SplitView.Pane>
<SplitView.Content>
<ScrollViewer Padding="0,0,16,0">
<ItemsControl
Margin="16,0,0,16"
ItemsSource="{Binding Achievements}">
<!--ContentThemeTransition here can make items blinking,
cause we are using ItemsStackPanel-->
<!--<ItemsControl.Transitions>
<ContentThemeTransition/>
</ItemsControl.Transitions>-->
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<ItemsStackPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid
CornerRadius="{ThemeResource ControlCornerRadius}"
Background="{ThemeResource CardBackgroundBrush}"
BorderThickness="{ThemeResource CardBorderThickness}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
Padding="8"
Margin="0,16,0,0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
MinHeight="48">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<!-- Icon -->
<ColumnDefinition Width="*"/>
<!-- Header and subtitle -->
<ColumnDefinition Width="Auto"/>
<!-- Action control -->
</Grid.ColumnDefinitions>
<CheckBox
Margin="6,0,0,0"
Style="{StaticResource DefaultCheckBoxStyle}"
Padding="16,0,0,0"
Grid.Column="1">
<CheckBox.Content>
<StackPanel>
<TextBlock Text="{Binding Title}"/>
<TextBlock
Margin="0,2,0,0"
Style="{StaticResource SecondaryTextStyle}"
Text="{Binding Description}"/>
</StackPanel>
</CheckBox.Content>
</CheckBox>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</SplitView.Content>
</SplitView>
</Grid>
</shcc:CancellablePage>
</shcc:CancellablePage>

View File

@@ -16,17 +16,21 @@ namespace Snap.Hutao.View.Page;
/// </summary>
public sealed partial class AnnouncementContentPage : Microsoft.UI.Xaml.Controls.Page
{
// apply in dark mode
private const string LightColor1 = "color:rgba(255,255,255,1)";
private const string LightColor2 = "color:rgba(238,238,238,1)";
private const string LightColor3 = "color:rgba(204,204,204,1)";
private const string LightColor4 = "color:rgba(198,196,191,1)";
private const string LightColor5 = "color:rgba(170,170,170,1)";
private const string LightAccentColor = "background-color: rgb(0,40,70)";
// find in content
private const string DarkColor1 = "color:rgba(0,0,0,1)";
private const string DarkColor2 = "color:rgba(17,17,17,1)";
private const string DarkColor3 = "color:rgba(51,51,51,1)";
private const string DarkColor4 = "color:rgba(57,59,64,1)";
private const string DarkColor5 = "color:rgba(85,85,85,1)";
private const string DarkAccentColor1 = "background-color: rgb(255, 215, 185);";
// support click open browser.
private const string MihoyoSDKDefinition =
@@ -76,7 +80,8 @@ openInWebview: function(url){ location.href = url }}";
.Replace(DarkColor5, LightColor5)
.Replace(DarkColor4, LightColor4)
.Replace(DarkColor3, LightColor3)
.Replace(DarkColor2, LightColor2);
.Replace(DarkColor2, LightColor2)
.Replace(DarkAccentColor1, LightAccentColor);
}
// wrap a default color body around

View File

@@ -10,7 +10,7 @@
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
d:DataContext="{d:DesignInstance shv:SettingViewModel}">
<Page.Resources>
<Style TargetType="Button" BasedOn="{StaticResource DefaultButtonStyle}">
<Style TargetType="Button" BasedOn="{StaticResource SettingButtonStyle}">
<Setter Property="MinWidth" Value="160"/>
</Style>
</Page.Resources>

View File

@@ -3,7 +3,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.WinUI.UI;
using Microsoft.VisualStudio.Threading;
using Snap.Hutao.Control.Cancellable;
using Snap.Hutao.Factory.Abstraction;
using Snap.Hutao.Model.Metadata.Achievement;
@@ -20,7 +19,9 @@ namespace Snap.Hutao.ViewModel;
internal class AchievementViewModel : ObservableObject, ISupportCancellation
{
private readonly IMetadataService metadataService;
private AdvancedCollectionView? achievementsView;
private AdvancedCollectionView? achievements;
private IList<AchievementGoal>? achievementGoals;
private AchievementGoal? selectedAchievementGoal;
/// <summary>
/// 构造一个新的成就视图模型
@@ -39,10 +40,32 @@ internal class AchievementViewModel : ObservableObject, ISupportCancellation
/// <summary>
/// 成就视图
/// </summary>
public AdvancedCollectionView? AchievementsView
public AdvancedCollectionView? Achievements
{
get => achievementsView;
set => SetProperty(ref achievementsView, value);
get => achievements;
set => SetProperty(ref achievements, value);
}
/// <summary>
/// 成就分类
/// </summary>
public IList<AchievementGoal>? AchievementGoals
{
get => achievementGoals;
set => SetProperty(ref achievementGoals, value);
}
/// <summary>
/// 选中的成就分类
/// </summary>
public AchievementGoal? SelectedAchievementGoal
{
get => selectedAchievementGoal;
set
{
SetProperty(ref selectedAchievementGoal, value);
OnGoalChanged(value);
}
}
/// <summary>
@@ -50,17 +73,22 @@ internal class AchievementViewModel : ObservableObject, ISupportCancellation
/// </summary>
public ICommand OpenUICommand { get; }
private async Task OpenUIAsync(CancellationToken token)
private async Task OpenUIAsync()
{
using (CancellationTokenExtensions.CombinedCancellationToken combined = token.CombineWith(CancellationToken))
if (await metadataService.InitializeAsync(CancellationToken))
{
if (await metadataService.InitializeAsync(combined.Token))
{
IEnumerable<Achievement> achievements = await metadataService.GetAchievementsAsync(combined.Token);
// TODO
AchievementsView = new(achievements.ToList());
}
Achievements = new(await metadataService.GetAchievementsAsync(CancellationToken), true);
AchievementGoals = await metadataService.GetAchievementGoalsAsync(CancellationToken);
}
}
}
private void OnGoalChanged(AchievementGoal? goal)
{
if (Achievements != null)
{
Achievements.Filter = goal != null
? ((object o) => o is Achievement achi && achi.Goal == goal.Id)
: ((object o) => true);
}
}
}

View File

@@ -41,7 +41,7 @@ internal class ExperimentalFeaturesViewModel : ObservableObject
private Task OpenCacheFolderAsync(CancellationToken token)
{
return Launcher.LaunchFolderAsync(App.Current.CacheFolder).AsTask(token);
return Launcher.LaunchFolderAsync(App.CacheFolder).AsTask(token);
}
private Task OpenDataFolderAsync(CancellationToken token)

View File

@@ -3,9 +3,10 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Xaml;
using Snap.Hutao.Core.Threading;
using Snap.Hutao.Factory.Abstraction;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Binding;
using Snap.Hutao.Service.Abstraction;
using Snap.Hutao.View.Dialog;
using System.Collections.Generic;
@@ -27,7 +28,7 @@ internal class UserViewModel : ObservableObject
private readonly IInfoBarService infoBarService;
private User? selectedUser;
private ObservableCollection<User>? userInfos;
private ObservableCollection<User>? users;
/// <summary>
/// 构造一个新的用户视图模型
@@ -64,7 +65,7 @@ internal class UserViewModel : ObservableObject
/// <summary>
/// 用户信息集合
/// </summary>
public ObservableCollection<User>? Users { get => userInfos; set => SetProperty(ref userInfos, value); }
public ObservableCollection<User>? Users { get => users; set => SetProperty(ref users, value); }
/// <summary>
/// 打开界面命令
@@ -90,20 +91,20 @@ internal class UserViewModel : ObservableObject
{
int validFlag = 4;
filteredCookie = new SortedDictionary<string, string>();
SortedDictionary<string, string> filter = new();
// O(1) to validate cookie
foreach ((string key, string value) in map)
{
if (key == AccountIdKey || key == "cookie_token" || key == "ltoken" || key == "ltuid")
{
validFlag--;
filteredCookie.Add(key, value);
filter.Add(key, value);
}
}
if (validFlag == 0)
{
filteredCookie = filter;
return true;
}
else
@@ -115,41 +116,43 @@ internal class UserViewModel : ObservableObject
private async Task OpenUIAsync()
{
Users = await userService.GetInitializedUsersAsync();
Users = await userService.GetUserCollectionAsync();
SelectedUser = userService.CurrentUser;
}
private async Task AddUserAsync()
{
MainWindow mainWindow = Ioc.Default.GetRequiredService<MainWindow>();
// Get cookie from user input
Window mainWindow = Ioc.Default.GetRequiredService<MainWindow>();
Result<bool, string> result = await new UserDialog(mainWindow).GetInputCookieAsync();
// user confirms the input
// User confirms the input
if (result.IsOk)
{
IDictionary<string, string> cookieMap = userService.ParseCookie(result.Value);
if (TryValidateCookie(cookieMap, out IDictionary<string, string>? filteredCookie))
if (TryValidateCookie(userService.ParseCookie(result.Value), out IDictionary<string, string>? filteredCookie))
{
string simplifiedCookie = string.Join(';', filteredCookie.Select(kvp => $"{kvp.Key}={kvp.Value}"));
User user = new() { Cookie = simplifiedCookie };
switch (await userService.TryAddUserAsync(user, filteredCookie[AccountIdKey]))
if (await userService.CreateUserAsync(simplifiedCookie) is User user)
{
case UserAddResult.Added:
infoBarService.Success($"用户 [{user.UserInfo!.Nickname}] 添加成功");
break;
case UserAddResult.Updated:
infoBarService.Success($"用户 [{user.UserInfo!.Nickname}] 更新成功");
break;
case UserAddResult.AlreadyExists:
infoBarService.Information($"用户 [{user.UserInfo!.Nickname}] 已经存在");
break;
case UserAddResult.InitializeFailed:
infoBarService.Warning("此 Cookie 无法获取用户信息,请重新输入");
break;
default:
throw Must.NeverHappen();
switch (await userService.TryAddUserAsync(user, filteredCookie[AccountIdKey]))
{
case UserAddResult.Added:
infoBarService.Success($"用户 [{user.UserInfo!.Nickname}] 添加成功");
break;
case UserAddResult.Updated:
infoBarService.Success($"用户 [{user.UserInfo!.Nickname}] 更新成功");
break;
case UserAddResult.AlreadyExists:
infoBarService.Information($"用户 [{user.UserInfo!.Nickname}] 已经存在");
break;
default:
throw Must.NeverHappen();
}
}
else
{
infoBarService.Warning("此 Cookie 无法获取用户信息,请重新输入");
}
}
else
@@ -161,19 +164,14 @@ internal class UserViewModel : ObservableObject
private async Task RemoveUserAsync(User? user)
{
if (!User.IsNone(user))
{
await userService.RemoveUserAsync(user);
infoBarService.Success($"用户 [{user.UserInfo!.Nickname}] 成功移除");
}
Verify.Operation(user != null, "待删除的用户不应为 null");
await userService.RemoveUserAsync(user);
infoBarService.Success($"用户 [{user.UserInfo?.Nickname}] 成功移除");
}
private void CopyCookie(User? user)
{
if (User.IsNone(user))
{
return;
}
Verify.Operation(user != null, "待复制 Cookie 的用户不应为 null");
IInfoBarService infoBarService = Ioc.Default.GetRequiredService<IInfoBarService>();
try

View File

@@ -20,6 +20,8 @@ namespace Snap.Hutao.ViewModel;
internal class WikiAvatarViewModel : ObservableObject
{
private readonly IMetadataService metadataService;
// filters
private readonly List<Selectable<string>> filterElementInfos;
private readonly List<Selectable<Pair<string, string>>> filterAssociationInfos;
private readonly List<Selectable<Pair<string, WeaponType>>> filterWeaponTypeInfos;
@@ -129,12 +131,12 @@ internal class WikiAvatarViewModel : ObservableObject
{
if (await metadataService.InitializeAsync())
{
IEnumerable<Avatar>? avatars = await metadataService.GetAvatarsAsync();
IList<Avatar> avatars = await metadataService.GetAvatarsAsync();
IOrderedEnumerable<Avatar> sorted = avatars
.OrderBy(avatar => avatar.BeginTime)
.ThenBy(avatar => avatar.Sort);
Avatars = new AdvancedCollectionView(new List<Avatar>(sorted), true);
Avatars = new AdvancedCollectionView(sorted.ToList(), true);
Avatars.MoveCurrentToFirst();
}
}
@@ -175,7 +177,10 @@ internal class WikiAvatarViewModel : ObservableObject
&& targeQualities.Contains(avatar.Quality)
&& targetBodies.Contains(avatar.Body);
Avatars.MoveCurrentToFirst();
if (!Avatars.Contains(Selected))
{
Avatars.MoveCurrentToFirst();
}
}
}
}

View File

@@ -35,7 +35,7 @@ internal class UserClient
/// <param name="user">用户</param>
/// <param name="token">取消令牌</param>
/// <returns>详细信息</returns>
public async Task<UserInfo?> GetUserFullInfoAsync(Model.Entity.User user, CancellationToken token = default)
public async Task<UserInfo?> GetUserFullInfoAsync(Model.Binding.User user, CancellationToken token = default)
{
Response<UserFullInfoWrapper>? resp = await httpClient
.UsingDynamicSecret()

View File

@@ -1,7 +1,7 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Binding;
using System.Net.Http;
namespace Snap.Hutao.Web.Hoyolab;
@@ -19,11 +19,7 @@ internal static class HttpClientCookieExtensions
/// <returns>客户端</returns>
internal static HttpClient SetUser(this HttpClient httpClient, User user)
{
if (!User.IsNone(user))
{
httpClient.DefaultRequestHeaders.Set("Cookie", user.Cookie);
}
httpClient.DefaultRequestHeaders.Set("Cookie", user.Cookie);
return httpClient;
}
}

View File

@@ -2,6 +2,7 @@
// Licensed under the MIT license.
using Snap.Hutao.Extension;
using Snap.Hutao.Model.Binding;
using Snap.Hutao.Web.Response;
using System.Collections.Generic;
using System.Net.Http;
@@ -33,7 +34,7 @@ internal class UserGameRoleClient
/// <param name="user">用户</param>
/// <param name="token">取消令牌</param>
/// <returns>用户角色信息</returns>
public async Task<List<UserGameRole>> GetUserGameRolesAsync(Model.Entity.User user, CancellationToken token = default)
public async Task<List<UserGameRole>> GetUserGameRolesAsync(User user, CancellationToken token = default)
{
Response<ListWrapper<UserGameRole>>? resp = await httpClient
.SetUser(user)

View File

@@ -2,7 +2,7 @@
// Licensed under the MIT license.
using Snap.Hutao.Extension;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Binding;
using Snap.Hutao.Web.Hoyolab.DynamicSecret;
using Snap.Hutao.Web.Hoyolab.Takumi.GameRecord.Avatar;
using Snap.Hutao.Web.Response;

View File

@@ -294,7 +294,7 @@ internal class HutaoClient : ISupportAsyncInitialization
/// <param name="user">用户</param>
/// <param name="token">取消令牌</param>
/// <returns>玩家记录</returns>
public async Task<PlayerRecord> GetPlayerRecordAsync(User user, CancellationToken token = default)
public async Task<PlayerRecord> GetPlayerRecordAsync(Snap.Hutao.Model.Binding.User user, CancellationToken token = default)
{
PlayerInfo? playerInfo = await gameRecordClient
.GetPlayerInfoAsync(user, token)