更新框架

This commit is contained in:
chfei committed 2026-10-27 14:35:03 +08:00
1 parent ef50fa0620
commit 84b28f9672
72 files changed
+2222 -282

No files matched your search

@@ -0,0 +1,61 @@
using Store.Management.Framework.Configs;
using System.Diagnostics;
using System.Reflection;
using System.Security;
using System.Security.Principal;
namespace Store.Management.Framework;
/// <summary>当前进程的应用信息;目录与持久化设置分别由 FolderConfig 和设置服务管理。</summary>
public static class Config
{
public static Guid SessionID { get; } = Guid.NewGuid();
/// <summary>进程启动时间(本地时间),不受首次访问 Config 的时机影响。</summary>
public static DateTime AppStartTime { get; } = GetAppStartTime();
public static string AppName => FolderConfig.AppName;
public static string AppFullName => AppName;
public static Version WindowsVersion => Environment.OSVersion.Version;
/// <summary>优先使用入口程序集文件版本;单文件发布等场景回退到程序集版本。</summary>
public static Version Version { get; } = GetVersion();
/// <summary>入口程序集的编译配置,不使用是否附加调试器作为判断依据。</summary>
public static bool IsRelease { get; } = !string.Equals(
EntryAssembly.GetCustomAttribute<AssemblyConfigurationAttribute>()?.Configuration,
"Debug", StringComparison.OrdinalIgnoreCase);
public static bool IsAdministrator
{
get
{
if (!OperatingSystem.IsWindows()) return false;
try
{
using WindowsIdentity identity = WindowsIdentity.GetCurrent();
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
}
catch (SecurityException) { return false; }
catch (UnauthorizedAccessException) { return false; }
}
}
private static Assembly EntryAssembly => Assembly.GetEntryAssembly() ?? typeof(Config).Assembly;
public static string GetFullVersionString() =>
$"{Version} - {(IsRelease ? "Release" : "Debug")}{(IsAdministrator ? " - 管理员" : string.Empty)}";
private static Version GetVersion()
{
string? fileVersion = EntryAssembly.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version;
return System.Version.TryParse(fileVersion, out Version? version)
? version : EntryAssembly.GetName().Version ?? new Version(0, 0, 0, 0);
}
private static DateTime GetAppStartTime()
{
using Process process = Process.GetCurrentProcess();
return process.StartTime;
}
}
@@ -0,0 +1,51 @@
using MAX.Framework.Interfaces;
using System.IO;
namespace Store.Management.Framework.Configs;
/// <summary>应用目录约定。路径读取不创建目录,保存配置时自动创建所需目录。</summary>
public sealed class FolderConfig : IFolderConfig
{
/// <summary>供启动配置使用;容器也可通过 IFolderConfig 解析目录配置。</summary>
public static FolderConfig Instance { get; } = new();
public FolderConfig() { }
string IFolderConfig.ConfigFolder => ConfigFolder;
string IFolderConfig.DataFolder => DataFolder;
string IFolderConfig.LoggingFolder => LoggingFolder;
string IFolderConfig.CacheFolder => CacheFolder;
public const string AppName = "Store.Management";
/// <summary>安装目录,用于读取随程序分发的资源。</summary>
public static string ExeFolder => AppContext.BaseDirectory;
/// <summary>当前用户的数据根目录:%LOCALAPPDATA%\Store.Management。</summary>
public static string AppFolder => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppName);
public static string ConfigFolder => Path.Combine(AppFolder, "Config");
public static string DataFolder => Path.Combine(AppFolder, "Data");
public static string LoggingFolder => Path.Combine(AppFolder, "Logs");
public static string DatabaseFolder => Path.Combine(AppFolder, "Db");
public static string CacheFolder => Path.Combine(AppFolder, "Cache");
public static string AppSettingsFilePath => GetSettingsFilePath("AppSettings.json");
/// <summary>获取配置文件路径,仅接受文件名,防止写出配置目录。</summary>
public static string GetSettingsFilePath(string fileName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
if (fileName is "." or ".." || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
|| fileName.EndsWith('.') || fileName.EndsWith(' '))
throw new ArgumentException("配置文件名不能包含路径、非法字符或以点、空格结尾。", nameof(fileName));
return Path.Combine(ConfigFolder, fileName);
}
/// <summary>按需在宿主启动时创建应用目录;创建失败向调用者报告。</summary>
public static void Initialize()
{
foreach (string path in new[] { ConfigFolder, DataFolder, LoggingFolder, DatabaseFolder, CacheFolder })
Directory.CreateDirectory(path);
}
}
@@ -78,6 +78,12 @@ public partial class DialogWindow : WindowBase, IDialogWindow
protected override void OnContentChanged(object oldContent, object newContent)
{
// InitializeComponent 设置初始内容时,窗口样式尚未加载。
if (!isConstructorSuccessful)
{
base.OnContentChanged(oldContent, newContent);
return;
}
this.Style = dialogWindowStyle;
if (newContent is UserControl control)
{
@@ -124,9 +130,9 @@ public partial class DialogWindow : WindowBase, IDialogWindow
protected override void OnStyleChanged(Style oldStyle, Style newStyle)
{
if (newStyle.TargetType == typeof(Window))
if (newStyle?.TargetType == typeof(Window))
{
if (isConstructorSuccessful)
if (isConstructorSuccessful && !ReferenceEquals(dialogWindowStyle, newStyle))
{
dialogWindowStyle.Merge(newStyle);
}
@@ -0,0 +1,15 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace Store.Management.Framework.Converters;
/// <summary>空字符串或 null 显示占位内容,其他值折叠;仅用于单向绑定。</summary>
public sealed class StringNullOrEmpty2VisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is null || value is string { Length: 0 } ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> Binding.DoNothing;
}
@@ -1,51 +1,150 @@
using Prism.Ioc;
using Prism.Mvvm;
using Prism.Services.Dialogs;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using Store.Management.Framework.Extensions;
using MAX.Framework.Logger;
using NLog;
using Prism.Autofac;
using Prism.Ioc;
using Prism.Services.Dialogs;
using Store.Management.Framework.Configs;
using Store.Management.Framework.Extensions;
using Store.Management.Framework.Services;
using System.Threading;
using System.Windows;
using System.Windows.Threading;
namespace Store.Management.Framework;
public abstract class FrameworkApplication : PrismApplication
{
private Mutex? _mutex;
private bool _ownsMutex;
private int _fatalHandling;
private int _shutdownRequested;
public static bool ShuttingDown { get; private set; }
public static bool IsAppInitialized { get; private set; }
public static event Action<Exception>? FatalErrorRaised;
protected FrameworkApplication()
{
protected FrameworkApplication()
{
DispatcherUnhandledException += OnDispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
}
/// <summary>返回 null 可允许多开;默认限制同一登录会话中的应用实例。</summary>
public virtual string? GetMutexName() => @"Local\Store.Management";
protected override void OnStartup(StartupEventArgs e)
{
try
{
//string? name = GetMutexName();
//if (!string.IsNullOrWhiteSpace(name))
//{
// _mutex = new Mutex(true, name, out _ownsMutex);
// if (!_ownsMutex)
// {
// MessageBox.Show("程序已在运行。", Config.AppName, MessageBoxButton.OK, MessageBoxImage.Information);
// Shutdown();
// return;
// }
//}
FolderConfig.Initialize();
base.OnStartup(e);
ApplicationLogService.Initialize();
NLogger.Instance.Info($"软件启动:{Config.AppFullName},版本:{Config.GetFullVersionString()},会话:{Config.SessionID},启动时间:{Config.AppStartTime:O},Windows:{Config.WindowsVersion}");
DISource.Resolver = Resolve;
}
catch (Exception ex) { Fatal(ex); }
}
protected override void OnStartup(StartupEventArgs e)
{
DISource.Resolver = Resolve;
base.OnStartup(e);
}
protected override void OnInitialized()
{
base.OnInitialized();
IsAppInitialized = true;
NLogger.Instance.Info("软件初始化成功,进入主页面");
}
protected override void OnInitialized()
{
(Container as IContainerExtension).RegisterDialog<DialogWindow>();
base.OnInitialized();
}
protected override void RegisterRequiredTypes(IContainerRegistry registry)
{
base.RegisterRequiredTypes(registry);
registry.RegisterDialogWindow<Controls.DialogWindow>();
}
private object Resolve(Type type, string name)
{
if (type == null || Container is null) return null!;
return name != null ? Container.Resolve(type, name) : Container.Resolve(type);
}
private object Resolve(Type type, string name)
{
if (type == null || Container is null)
{
return null;
}
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
e.Handled = true;
Fatal(e.Exception);
}
if (name != null)
{
return Container.Resolve(type, name);
}
private void OnDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Exception exception = e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString());
// CLR 终止期间同步记录,不等待 UI 线程。
if (e.IsTerminating) ApplicationLogService.WriteFatal(exception, "AppDomain.UnhandledException");
else Fatal(exception);
}
return Container.Resolve(type);
}
private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
ApplicationLogService.WriteFatal(e.Exception, "TaskScheduler.UnobservedTaskException");
e.SetObserved();
}
/// <summary>先同步保存报告,再通知订阅者并关闭应用。</summary>
public static void Fatal(Exception exception)
{
ArgumentNullException.ThrowIfNull(exception);
string? path = ApplicationLogService.WriteFatal(exception, "Fatal");
if (Current is not FrameworkApplication app || Interlocked.Exchange(ref app._fatalHandling, 1) != 0) return;
foreach (Action<Exception> handler in FatalErrorRaised?.GetInvocationList() ?? [])
{
try { handler(exception); }
catch (Exception ex) { ApplicationLogService.WriteFatal(ex, "FatalErrorRaised"); }
}
void ShowErrorAndShutdown()
{
try
{
MessageBox.Show($"软件发生致命错误,即将关闭。\n{exception.Message}\n\n日志:{path ?? "写入失败,请查看调试输出"}",
Config.AppName, MessageBoxButton.OK, MessageBoxImage.Error);
}
finally { TotalShutdown(-1); }
}
if (app.Dispatcher.CheckAccess()) ShowErrorAndShutdown();
else if (!app.Dispatcher.HasShutdownStarted) app.Dispatcher.BeginInvoke((Action)ShowErrorAndShutdown);
}
/// <summary>通过 WPF 正常退出,执行退出清理。</summary>
public static void TotalShutdown(int exitCode = 0)
{
if (Current is not FrameworkApplication app || Interlocked.Exchange(ref app._shutdownRequested, 1) != 0) return;
ShuttingDown = true;
if (app.Dispatcher.CheckAccess()) app.Shutdown(exitCode);
else if (!app.Dispatcher.HasShutdownStarted) app.Dispatcher.BeginInvoke((Action)(() => app.Shutdown(exitCode)));
}
protected override void OnExit(ExitEventArgs e)
{
ShuttingDown = true;
try { base.OnExit(e); }
finally
{
DispatcherUnhandledException -= OnDispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException -= OnDomainUnhandledException;
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
DISource.Resolver = null!;
try { if (_ownsMutex) _mutex?.ReleaseMutex(); }
finally
{
_mutex?.Dispose();
try { NLogger.Instance.Info($"软件关闭,退出码:{e.ApplicationExitCode}"); }
finally { LogManager.Shutdown(); }
}
}
}
}
@@ -0,0 +1,15 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<sys:Double x:Key="DefaultControlHeight">28</sys:Double>
<sys:Double x:Key="SmallControlHeight">10</sys:Double>
<Thickness x:Key="DefaultControlPadding">10,5</Thickness>
<Thickness x:Key="DefaultInputPadding">8,0</Thickness>
<CornerRadius x:Key="DefaultCornerRadius">2</CornerRadius>
<sys:Double x:Key="{x:Static SystemParameters.VerticalScrollBarButtonHeightKey}">30</sys:Double>
<sys:Double x:Key="LargeFontSize">24</sys:Double>
<sys:Double x:Key="HeadFontSize">20</sys:Double>
<sys:Double x:Key="SubHeadFontSize">16</sys:Double>
<sys:Double x:Key="TextFontSize">12</sys:Double>
</ResourceDictionary>
@@ -0,0 +1,60 @@
using MAX.Framework.Logger;
using NLog;
using NLog.Config;
using NLog.Targets;
using Store.Management.Framework.Configs;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace Store.Management.Framework.Services;
/// <summary>默认运行日志与不依赖容器的同步崩溃报告。</summary>
public static class ApplicationLogService
{
public static void Initialize()
{
// 在加载文件配置前注入目录,配置自动重载时仍可读取。
GlobalDiagnosticsContext.Set("LoggingFolder", FolderConfig.LoggingFolder);
// 尊重宿主提供的 NLog 配置。
if (LogManager.Configuration != null) return;
var target = new FileTarget("application")
{
FileName = Path.Combine(FolderConfig.LoggingFolder, "${shortdate}.log"),
Layout = "${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}",
Encoding = Encoding.UTF8,
MaxArchiveDays = 30
};
var configuration = new LoggingConfiguration();
configuration.AddRule(LogLevel.Debug, LogLevel.Fatal, target);
LogManager.Configuration = configuration;
}
/// <returns>报告路径;所有目录均写入失败时返回 null。</returns>
public static string? WriteFatal(Exception exception, string source)
{
ArgumentNullException.ThrowIfNull(exception);
string id = $"{DateTime.UtcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}";
string report = $"Id: {id}\nTime: {DateTimeOffset.Now:O}\nSource: {source}\nApp: {Config.AppFullName}\nVersion: {Config.Version}\nSession: {Config.SessionID}\nStarted: {Config.AppStartTime:O}\nWindows: {Config.WindowsVersion}\n{exception}";
Debug.WriteLine(report);
string? savedPath = null;
foreach (string directory in new[] { FolderConfig.LoggingFolder, Path.Combine(Path.GetTempPath(), FolderConfig.AppName, "Logs") })
{
try
{
Directory.CreateDirectory(directory);
savedPath = Path.Combine(directory, $"fatal-{id}.log");
File.WriteAllText(savedPath, report, Encoding.UTF8);
break;
}
catch (Exception ex) { savedPath = null; Debug.WriteLine(ex); }
}
try
{
LogManager.GetLogger("GlobalLogger").Fatal(exception, "{0}: {1}; Report: {2}", source, id, savedPath);
LogManager.Flush(TimeSpan.FromSeconds(2));
}
catch (Exception ex) { Debug.WriteLine(ex); }
return savedPath;
}
}
@@ -0,0 +1,99 @@
using System.IO;
using Newtonsoft.Json;
using Prism.Mvvm;
using Store.Management.Framework.Configs;
namespace Store.Management.Framework.Services;
public interface ISettingsServiceBase<T> where T : class
{
T Model { get; }
string FilePath { get; }
T GetDefaultModel();
void LoadSettings();
Task LoadSettingsAsync();
void SaveSettings();
Task SaveSettingsAsync();
}
/// <summary>
/// JSON 配置服务。缺失文件保留当前配置,加载失败保留旧模型并抛出异常。
/// 保存通过同目录临时文件替换目标;模型变更应在所属 UI 线程调用。
/// </summary>
public abstract class JsonSettingsServiceBase<T> : BindableBase, ISettingsServiceBase<T> where T : class
{
private readonly SemaphoreSlim _gate = new(1, 1);
private T? _model;
/// <summary>默认按模型类型名保存到用户配置目录,派生类可覆盖文件名或完整路径。</summary>
public virtual string FilePath => FolderConfig.GetSettingsFilePath(typeof(T).Name + ".json");
public abstract T GetDefaultModel();
public T Model
{
get => _model ??= GetDefaultModel() ?? throw new InvalidOperationException("默认配置不能为空。");
protected set
{
ArgumentNullException.ThrowIfNull(value);
SetProperty(ref _model, value);
}
}
public void LoadSettings()
{
T? loaded = LoadModelAsync().GetAwaiter().GetResult();
if (loaded is not null) Model = loaded;
}
public void SaveSettings() => SaveSettingsAsync().GetAwaiter().GetResult();
public async Task LoadSettingsAsync()
{
T? loaded = await LoadModelAsync();
if (loaded is not null) Model = loaded;
}
private async Task<T?> LoadModelAsync()
{
T? loaded = null;
await _gate.WaitAsync().ConfigureAwait(false);
try
{
string path = GetFullPath();
try
{
string json = await File.ReadAllTextAsync(path).ConfigureAwait(false);
loaded = JsonConvert.DeserializeObject<T>(json)
?? throw new JsonSerializationException($"配置文件不能为 null:{path}");
}
catch (FileNotFoundException) { }
catch (DirectoryNotFoundException) { }
}
finally { _gate.Release(); }
return loaded;
}
public async Task SaveSettingsAsync()
{
// 在首次等待前读取模型,避免在后台线程初始化默认模型。
string json = JsonConvert.SerializeObject(Model, Formatting.Indented);
await _gate.WaitAsync().ConfigureAwait(false);
string? temporaryPath = null;
try
{
string path = GetFullPath();
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
await File.WriteAllTextAsync(temporaryPath, json).ConfigureAwait(false);
File.Move(temporaryPath, path, overwrite: true);
}
finally
{
try { if (temporaryPath is not null && File.Exists(temporaryPath)) File.Delete(temporaryPath); }
finally { _gate.Release(); }
}
}
private string GetFullPath()
{
ArgumentException.ThrowIfNullOrWhiteSpace(FilePath);
return Path.GetFullPath(FilePath);
}
}
@@ -1,14 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HandyControl" Version="3.6.0.9" />
<Content Remove="C:\Users\chaih\.nuget\packages\handycontrol\3.6.2.1\contentFiles\any\net8.0-windows7.0\LangProvider.tt" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HandyControl" Version="3.6.2.1" />
<ProjectReference Include="..\Core\MAX.Core\MAX.Core.csproj" />
<ProjectReference Include="..\Core\MAX.Framework\MAX.Framework.csproj" />
</ItemGroup>
@@ -0,0 +1,32 @@
namespace Store.Management.Framework.Utilities;
/// <summary>按订阅顺序等待异步处理器;异常向调用者传播并停止后续处理。</summary>
public sealed class AsyncEvent<TEventArgs> where TEventArgs : EventArgs
{
private readonly object _gate = new();
private readonly List<Func<object?, TEventArgs, Task>> _handlers = [];
public void Subscribe(Func<object?, TEventArgs, Task> callback)
{
ArgumentNullException.ThrowIfNull(callback);
lock (_gate) { _handlers.Add(callback); }
}
public void Unsubscribe(Func<object?, TEventArgs, Task> callback)
{
ArgumentNullException.ThrowIfNull(callback);
lock (_gate)
{
int index = _handlers.LastIndexOf(callback);
if (index >= 0) _handlers.RemoveAt(index);
}
}
public async Task InvokeAsync(object? sender, TEventArgs eventArgs)
{
ArgumentNullException.ThrowIfNull(eventArgs);
Func<object?, TEventArgs, Task>[] snapshot;
lock (_gate) { snapshot = _handlers.ToArray(); }
foreach (var callback in snapshot) await callback(sender, eventArgs);
}
}
@@ -0,0 +1,15 @@
namespace Store.Management.Framework.Utilities;
/// <summary>在释放时执行一次清理操作,可安全地重复或并发释放。</summary>
public sealed class DisposeAction : IDisposable
{
private Action? _action;
public DisposeAction(Action action)
{
ArgumentNullException.ThrowIfNull(action);
_action = action;
}
public void Dispose() => Interlocked.Exchange(ref _action, null)?.Invoke();
}