From 5b7238befa62a26c148416b1da4c527ef20be4ee Mon Sep 17 00:00:00 2001 From: "GukSang.Jin" Date: Thu, 5 Mar 2026 10:03:44 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PetWashControl/Services/ConfigurationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PetWashControl/Services/ConfigurationService.cs b/PetWashControl/Services/ConfigurationService.cs index 4347020..35fca4b 100644 --- a/PetWashControl/Services/ConfigurationService.cs +++ b/PetWashControl/Services/ConfigurationService.cs @@ -3,7 +3,7 @@ namespace PetWashControl.Services; public class ConfigurationService { // https://localhost:7203/ 本地 - public string ApiBaseUrl { get; set; } = "http://localhost:5000/"; + public string ApiBaseUrl { get; set; } = "http://101.132.182.216:8080/"; public string MqttBrokerHost { get; set; } = "101.132.182.216"; public int MqttBrokerPort { get; set; } = 1883; public string MqttApiKey { get; set; } = "dc240ab5ec"; From 54b3448e315d6ceeb3c3b576d9da07f1862baa02 Mon Sep 17 00:00:00 2001 From: "GukSang.Jin" Date: Mon, 16 Mar 2026 15:38:08 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E6=9B=B4=E6=96=B020260316?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 12 + .gitignore | 1 + PetWash.Api/Controllers/OrdersController.cs | 68 +- PetWash.Api/Controllers/PaymentsController.cs | 57 ++ PetWash.Api/Models/CreateOrderResponse.cs | 9 + PetWash.Api/Models/PaymentStatusResponse.cs | 11 + PetWash.Api/Program.cs | 6 + PetWash.Api/Services/OrderService.cs | 9 +- PetWash.Api/Services/WeChatPayService.cs | 602 ++++++++++++++++++ PetWash.Api/appsettings.json | 16 +- PetWashControl/Models/CreateOrderResponse.cs | 9 + .../Models/PaymentStatusResponse.cs | 11 + PetWashControl/PetWashControl.csproj | 1 + PetWashControl/Services/ApiService.cs | 17 +- PetWashControl/ViewModels/MainViewModel.cs | 305 ++++++++- PetWashControl/Views/MainWindow.xaml | 28 +- 16 files changed, 1132 insertions(+), 30 deletions(-) create mode 100644 PetWash.Api/Controllers/PaymentsController.cs create mode 100644 PetWash.Api/Models/CreateOrderResponse.cs create mode 100644 PetWash.Api/Models/PaymentStatusResponse.cs create mode 100644 PetWash.Api/Services/WeChatPayService.cs create mode 100644 PetWashControl/Models/CreateOrderResponse.cs create mode 100644 PetWashControl/Models/PaymentStatusResponse.cs diff --git a/.env.example b/.env.example index 9e832ac..9e49858 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,15 @@ LOG_LEVEL=Information # MQTT_PORT=1883 # MQTT_USERNAME= # MQTT_PASSWORD= + +# WeChat Pay Native +# WECHATPAY__APPID=wx1234567890abcdef +# WECHATPAY__MERCHANTID=1900000001 +# WECHATPAY__CERTIFICATESERIALNUMBER=7777777777777777777777777777777777777777 +# WECHATPAY__PRIVATEKEYPATH=certs/apiclient_key.pem +# WECHATPAY__NOTIFYURL=https://your-domain.example.com/api/payments/wechat/notify +# WECHATPAY__APIV3KEY=32bytesapiv3keyxxxxxxxxxxxxxxxx +# WECHATPAY__PLATFORMPUBLICKEYPATH=certs/wechatpay_public_key.pem +# WECHATPAY__PLATFORMPUBLICKEYSERIAL=PUB_KEY_ID_xxxxxxxxxxxxxxxxxxxxxxxx +# WECHATPAY__CURRENCY=CNY +# WECHATPAY__ORDEREXPIREMINUTES=5 diff --git a/.gitignore b/.gitignore index d41be38..895cd9e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ logs/ # Temporary files tmp/ temp/ +.dotnet/ diff --git a/PetWash.Api/Controllers/OrdersController.cs b/PetWash.Api/Controllers/OrdersController.cs index e8ce945..56d075b 100644 --- a/PetWash.Api/Controllers/OrdersController.cs +++ b/PetWash.Api/Controllers/OrdersController.cs @@ -9,10 +9,12 @@ namespace PetWash.Api.Controllers; public class OrdersController : ControllerBase { private readonly OrderService _orderService; + private readonly WeChatPayService _weChatPayService; - public OrdersController(OrderService orderService) + public OrdersController(OrderService orderService, WeChatPayService weChatPayService) { _orderService = orderService; + _weChatPayService = weChatPayService; } [HttpPost] @@ -21,12 +23,24 @@ public class OrdersController : ControllerBase try { var order = await _orderService.CreateOrderAsync(request.PackageId); - return Ok(order); + var payment = await _weChatPayService.CreateNativePayAsync(order, HttpContext.RequestAborted); + + return Ok(new CreateOrderResponse + { + Order = order, + CodeUrl = payment.CodeUrl, + OutTradeNo = payment.OutTradeNo, + ExpiresAt = payment.ExpiresAt + }); } catch (ArgumentException ex) { return BadRequest(ex.Message); } + catch (InvalidOperationException ex) + { + return StatusCode(StatusCodes.Status502BadGateway, ex.Message); + } } [HttpPost("{id}/payment")] @@ -39,6 +53,56 @@ public class OrdersController : ControllerBase return Ok(order); } + [HttpGet("{id}/payment-status")] + public async Task GetPaymentStatus(int id, [FromQuery] string outTradeNo) + { + if (string.IsNullOrWhiteSpace(outTradeNo)) + { + return BadRequest("outTradeNo is required."); + } + + var order = await _orderService.GetOrderAsync(id); + if (order == null) + { + return NotFound(); + } + + if (order.IsPaid) + { + return Ok(new PaymentStatusResponse + { + Order = order, + IsPaid = true, + TradeState = "SUCCESS", + OutTradeNo = outTradeNo, + Message = "Order already confirmed." + }); + } + + try + { + var payment = await _weChatPayService.QueryOrderByOutTradeNoAsync(outTradeNo, HttpContext.RequestAborted); + if (string.Equals(payment.TradeState, "SUCCESS", StringComparison.OrdinalIgnoreCase)) + { + order = await _orderService.ConfirmPaymentAsync(id) ?? order; + } + + return Ok(new PaymentStatusResponse + { + Order = order, + IsPaid = order.IsPaid, + TradeState = payment.TradeState, + OutTradeNo = payment.OutTradeNo, + TransactionId = payment.TransactionId, + Message = order.IsPaid ? "Payment confirmed." : "Waiting for payment." + }); + } + catch (InvalidOperationException ex) + { + return StatusCode(StatusCodes.Status502BadGateway, ex.Message); + } + } + [HttpGet("{id}")] public async Task GetOrder(int id) { diff --git a/PetWash.Api/Controllers/PaymentsController.cs b/PetWash.Api/Controllers/PaymentsController.cs new file mode 100644 index 0000000..4d74d35 --- /dev/null +++ b/PetWash.Api/Controllers/PaymentsController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using PetWash.Api.Services; + +namespace PetWash.Api.Controllers; + +[ApiController] +[Route("api/payments/wechat")] +public class PaymentsController : ControllerBase +{ + private readonly OrderService _orderService; + private readonly WeChatPayService _weChatPayService; + private readonly ILogger _logger; + + public PaymentsController( + OrderService orderService, + WeChatPayService weChatPayService, + ILogger logger) + { + _orderService = orderService; + _weChatPayService = weChatPayService; + _logger = logger; + } + + [HttpPost("notify")] + public async Task Notify(CancellationToken cancellationToken) + { + using var reader = new StreamReader(Request.Body); + var body = await reader.ReadToEndAsync(cancellationToken); + + try + { + var notification = await _weChatPayService.ParsePaymentNotificationAsync( + body, + Request.Headers, + cancellationToken); + + if (string.Equals(notification.TradeState, "SUCCESS", StringComparison.OrdinalIgnoreCase)) + { + var order = await _orderService.ConfirmPaymentAsync(notification.OrderId); + if (order == null) + { + _logger.LogWarning( + "WeChat payment notification references a missing order. OrderId={OrderId}, OutTradeNo={OutTradeNo}", + notification.OrderId, + notification.OutTradeNo); + } + } + + return Ok(new { code = "SUCCESS", message = "成功" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process WeChat payment notification."); + return BadRequest(new { code = "FAIL", message = ex.Message }); + } + } +} diff --git a/PetWash.Api/Models/CreateOrderResponse.cs b/PetWash.Api/Models/CreateOrderResponse.cs new file mode 100644 index 0000000..0ac75f5 --- /dev/null +++ b/PetWash.Api/Models/CreateOrderResponse.cs @@ -0,0 +1,9 @@ +namespace PetWash.Api.Models; + +public sealed class CreateOrderResponse +{ + public required Order Order { get; init; } + public string CodeUrl { get; init; } = string.Empty; + public string OutTradeNo { get; init; } = string.Empty; + public DateTimeOffset? ExpiresAt { get; init; } +} diff --git a/PetWash.Api/Models/PaymentStatusResponse.cs b/PetWash.Api/Models/PaymentStatusResponse.cs new file mode 100644 index 0000000..11472a0 --- /dev/null +++ b/PetWash.Api/Models/PaymentStatusResponse.cs @@ -0,0 +1,11 @@ +namespace PetWash.Api.Models; + +public sealed class PaymentStatusResponse +{ + public required Order Order { get; init; } + public bool IsPaid { get; init; } + public string TradeState { get; init; } = string.Empty; + public string OutTradeNo { get; init; } = string.Empty; + public string TransactionId { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; +} diff --git a/PetWash.Api/Program.cs b/PetWash.Api/Program.cs index 6d1509e..5e10e48 100644 --- a/PetWash.Api/Program.cs +++ b/PetWash.Api/Program.cs @@ -27,6 +27,12 @@ builder.Services.AddDbContext(options => builder.Services.AddSingleton(); builder.Services.AddHostedService(provider => provider.GetRequiredService()); builder.Services.AddScoped(); +builder.Services.Configure(builder.Configuration.GetSection("WeChatPay")); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://api.mch.weixin.qq.com"); + client.Timeout = TimeSpan.FromSeconds(15); +}); // 添加CORS builder.Services.AddCors(options => diff --git a/PetWash.Api/Services/OrderService.cs b/PetWash.Api/Services/OrderService.cs index 4bd9f9a..45377f4 100644 --- a/PetWash.Api/Services/OrderService.cs +++ b/PetWash.Api/Services/OrderService.cs @@ -26,6 +26,7 @@ public class OrderService var order = new Order { PackageId = packageId, + Package = package, CreatedAt = DateTime.Now, Status = OrderStatus.WaitingPayment, IsPaid = false @@ -43,6 +44,12 @@ public class OrderService var order = await _context.Orders.Include(o => o.Package).FirstOrDefaultAsync(o => o.Id == orderId); if (order == null) return null; + if (order.IsPaid) + { + _logger.LogInformation("Order payment already confirmed: OrderId={OrderId}", orderId); + return order; + } + order.IsPaid = true; order.PaidAt = DateTime.Now; order.Status = OrderStatus.Paid; @@ -62,7 +69,7 @@ public class OrderService public async Task UpdateOrderStatusAsync(int orderId, OrderStatus status) { - var order = await _context.Orders.FindAsync(orderId); + var order = await _context.Orders.Include(o => o.Package).FirstOrDefaultAsync(o => o.Id == orderId); if (order == null) return null; order.Status = status; diff --git a/PetWash.Api/Services/WeChatPayService.cs b/PetWash.Api/Services/WeChatPayService.cs new file mode 100644 index 0000000..42b3d1e --- /dev/null +++ b/PetWash.Api/Services/WeChatPayService.cs @@ -0,0 +1,602 @@ +using System.Globalization; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; +using PetWash.Api.Models; + +namespace PetWash.Api.Services; + +public sealed class WeChatPayOptions +{ + public string AppId { get; set; } = string.Empty; + public string MerchantId { get; set; } = string.Empty; + public string CertificateSerialNumber { get; set; } = string.Empty; + public string PrivateKeyPath { get; set; } = string.Empty; + public string PrivateKeyPem { get; set; } = string.Empty; + public string NotifyUrl { get; set; } = string.Empty; + public string ApiV3Key { get; set; } = string.Empty; + public string PlatformPublicKeyPath { get; set; } = string.Empty; + public string PlatformPublicKeyPem { get; set; } = string.Empty; + public string PlatformPublicKeySerial { get; set; } = string.Empty; + public string Currency { get; set; } = "CNY"; + public int OrderExpireMinutes { get; set; } = 5; +} + +public sealed class WeChatNativePayResult +{ + public string CodeUrl { get; init; } = string.Empty; + public string OutTradeNo { get; init; } = string.Empty; + public DateTimeOffset? ExpiresAt { get; init; } +} + +public sealed class WeChatTransactionStatus +{ + public string OutTradeNo { get; init; } = string.Empty; + public string TradeState { get; init; } = string.Empty; + public string TransactionId { get; init; } = string.Empty; + public string Attach { get; init; } = string.Empty; + public string SuccessTime { get; init; } = string.Empty; +} + +public sealed class WeChatPaymentNotification +{ + public required int OrderId { get; init; } + public string OutTradeNo { get; init; } = string.Empty; + public string TradeState { get; init; } = string.Empty; + public string TransactionId { get; init; } = string.Empty; +} + +public sealed class WeChatPayService +{ + private const string NativePayPath = "/v3/pay/transactions/native"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly HttpClient _httpClient; + private readonly WeChatPayOptions _options; + private readonly ILogger _logger; + + public WeChatPayService( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + _httpClient = httpClient; + _options = options.Value; + _logger = logger; + } + + public async Task CreateNativePayAsync(Order order, CancellationToken cancellationToken = default) + { + ValidateOptions(); + + if (order.Package is null) + { + throw new InvalidOperationException("Package data is required to create a WeChat payment."); + } + + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.OrderExpireMinutes); + var outTradeNo = BuildOutTradeNo(order.Id); + var payload = new NativePayRequest + { + AppId = _options.AppId, + MerchantId = _options.MerchantId, + Description = $"{order.Package.Name} payment", + OutTradeNo = outTradeNo, + NotifyUrl = _options.NotifyUrl, + TimeExpire = expiresAt.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture), + Attach = $"orderId={order.Id}", + Amount = new NativePayAmount + { + Total = ConvertToFen(order.Package.Price), + Currency = string.IsNullOrWhiteSpace(_options.Currency) ? "CNY" : _options.Currency + } + }; + + var body = JsonSerializer.Serialize(payload, JsonOptions); + using var request = new HttpRequestMessage(HttpMethod.Post, NativePayPath) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + + var nonce = Guid.NewGuid().ToString("N"); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue( + "WECHATPAY2-SHA256-RSA2048", + BuildAuthorizationParameter(NativePayPath, HttpMethod.Post.Method, body, nonce, timestamp)); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var error = TryDeserialize(responseBody); + var message = error is null + ? responseBody + : $"{error.Code}: {error.Message}"; + + _logger.LogError( + "WeChat Native pay request failed. StatusCode={StatusCode}, Message={Message}", + (int)response.StatusCode, + message); + + throw new InvalidOperationException($"WeChat Native pay request failed: {message}"); + } + + var result = TryDeserialize(responseBody); + if (string.IsNullOrWhiteSpace(result?.CodeUrl)) + { + throw new InvalidOperationException("WeChat returned an empty code_url."); + } + + return new WeChatNativePayResult + { + CodeUrl = result.CodeUrl, + OutTradeNo = outTradeNo, + ExpiresAt = expiresAt + }; + } + + public async Task QueryOrderByOutTradeNoAsync( + string outTradeNo, + CancellationToken cancellationToken = default) + { + ValidateOptions(); + + if (string.IsNullOrWhiteSpace(outTradeNo)) + { + throw new ArgumentException("outTradeNo is required.", nameof(outTradeNo)); + } + + var canonicalUrl = + $"/v3/pay/transactions/out-trade-no/{Uri.EscapeDataString(outTradeNo)}?mchid={Uri.EscapeDataString(_options.MerchantId)}"; + using var request = new HttpRequestMessage(HttpMethod.Get, canonicalUrl); + + var nonce = Guid.NewGuid().ToString("N"); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue( + "WECHATPAY2-SHA256-RSA2048", + BuildAuthorizationParameter(canonicalUrl, HttpMethod.Get.Method, string.Empty, nonce, timestamp)); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var error = TryDeserialize(responseBody); + var message = error is null + ? responseBody + : $"{error.Code}: {error.Message}"; + + throw new InvalidOperationException($"WeChat order query failed: {message}"); + } + + var result = TryDeserialize(responseBody); + if (result is null) + { + throw new InvalidOperationException("WeChat order query returned an unreadable response."); + } + + return new WeChatTransactionStatus + { + OutTradeNo = result.OutTradeNo, + TradeState = result.TradeState, + TransactionId = result.TransactionId, + Attach = result.Attach, + SuccessTime = result.SuccessTime + }; + } + + public async Task ParsePaymentNotificationAsync( + string body, + IHeaderDictionary headers, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(headers); + ValidateNotificationOptions(); + + var timestamp = headers["Wechatpay-Timestamp"].ToString(); + var nonce = headers["Wechatpay-Nonce"].ToString(); + var signature = headers["Wechatpay-Signature"].ToString(); + var serial = headers["Wechatpay-Serial"].ToString(); + + if (string.IsNullOrWhiteSpace(timestamp) || + string.IsNullOrWhiteSpace(nonce) || + string.IsNullOrWhiteSpace(signature) || + string.IsNullOrWhiteSpace(serial)) + { + throw new InvalidOperationException("Missing WeChat notification headers."); + } + + VerifyNotificationSignature(timestamp, nonce, body, signature, serial); + + var notification = TryDeserialize(body) + ?? throw new InvalidOperationException("Invalid WeChat notification payload."); + + if (notification.Resource is null) + { + throw new InvalidOperationException("WeChat notification resource is missing."); + } + + var decryptedJson = DecryptNotificationResource(notification.Resource); + var transaction = TryDeserialize(decryptedJson) + ?? throw new InvalidOperationException("Unable to decrypt WeChat notification resource."); + + if (!TryExtractOrderId(transaction.Attach, out var orderId)) + { + throw new InvalidOperationException("Unable to resolve orderId from WeChat notification."); + } + + await Task.CompletedTask; + + return new WeChatPaymentNotification + { + OrderId = orderId, + OutTradeNo = transaction.OutTradeNo, + TradeState = transaction.TradeState, + TransactionId = transaction.TransactionId + }; + } + + private string BuildAuthorizationParameter(string canonicalUrl, string method, string body, string nonce, string timestamp) + { + var message = $"{method}\n{canonicalUrl}\n{timestamp}\n{nonce}\n{body}\n"; + var signature = Sign(message); + + return + $"mchid=\"{_options.MerchantId}\"," + + $"nonce_str=\"{nonce}\"," + + $"timestamp=\"{timestamp}\"," + + $"serial_no=\"{_options.CertificateSerialNumber}\"," + + $"signature=\"{signature}\""; + } + + private string Sign(string message) + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(GetPrivateKeyPem()); + var signatureBytes = rsa.SignData( + Encoding.UTF8.GetBytes(message), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return Convert.ToBase64String(signatureBytes); + } + + private string GetPrivateKeyPem() + { + if (!string.IsNullOrWhiteSpace(_options.PrivateKeyPem)) + { + return _options.PrivateKeyPem; + } + + var keyPath = _options.PrivateKeyPath; + if (string.IsNullOrWhiteSpace(keyPath)) + { + throw new InvalidOperationException("WeChat private key is not configured."); + } + + if (!Path.IsPathRooted(keyPath)) + { + keyPath = Path.Combine(AppContext.BaseDirectory, keyPath); + } + + if (!File.Exists(keyPath)) + { + throw new InvalidOperationException($"WeChat private key file was not found: {keyPath}"); + } + + return File.ReadAllText(keyPath); + } + + private void ValidateOptions() + { + var missingFields = new List(); + + if (string.IsNullOrWhiteSpace(_options.AppId)) + { + missingFields.Add("WeChatPay:AppId"); + } + + if (string.IsNullOrWhiteSpace(_options.MerchantId)) + { + missingFields.Add("WeChatPay:MerchantId"); + } + + if (string.IsNullOrWhiteSpace(_options.CertificateSerialNumber)) + { + missingFields.Add("WeChatPay:CertificateSerialNumber"); + } + + if (string.IsNullOrWhiteSpace(_options.NotifyUrl)) + { + missingFields.Add("WeChatPay:NotifyUrl"); + } + + if (string.IsNullOrWhiteSpace(_options.PrivateKeyPem) && string.IsNullOrWhiteSpace(_options.PrivateKeyPath)) + { + missingFields.Add("WeChatPay:PrivateKeyPath or WeChatPay:PrivateKeyPem"); + } + + if (missingFields.Count > 0) + { + throw new InvalidOperationException( + $"Missing WeChat Pay configuration: {string.Join(", ", missingFields)}"); + } + } + + private void ValidateNotificationOptions() + { + var missingFields = new List(); + + if (string.IsNullOrWhiteSpace(_options.ApiV3Key)) + { + missingFields.Add("WeChatPay:ApiV3Key"); + } + + if (string.IsNullOrWhiteSpace(_options.PlatformPublicKeyPem) && + string.IsNullOrWhiteSpace(_options.PlatformPublicKeyPath)) + { + missingFields.Add("WeChatPay:PlatformPublicKeyPath or WeChatPay:PlatformPublicKeyPem"); + } + + if (string.IsNullOrWhiteSpace(_options.PlatformPublicKeySerial)) + { + missingFields.Add("WeChatPay:PlatformPublicKeySerial"); + } + + if (missingFields.Count > 0) + { + throw new InvalidOperationException( + $"Missing WeChat notification configuration: {string.Join(", ", missingFields)}"); + } + } + + private void VerifyNotificationSignature( + string timestamp, + string nonce, + string body, + string signature, + string serial) + { + if (!string.Equals(serial, _options.PlatformPublicKeySerial, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Unexpected WeChat notification serial."); + } + + var signatureMessage = $"{timestamp}\n{nonce}\n{body}\n"; + var signatureBytes = Convert.FromBase64String(signature); + + using var rsa = RSA.Create(); + rsa.ImportFromPem(GetPlatformPublicKeyPem()); + + var verified = rsa.VerifyData( + Encoding.UTF8.GetBytes(signatureMessage), + signatureBytes, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + if (!verified) + { + throw new InvalidOperationException("WeChat notification signature verification failed."); + } + } + + private string GetPlatformPublicKeyPem() + { + if (!string.IsNullOrWhiteSpace(_options.PlatformPublicKeyPem)) + { + return _options.PlatformPublicKeyPem; + } + + var keyPath = _options.PlatformPublicKeyPath; + if (string.IsNullOrWhiteSpace(keyPath)) + { + throw new InvalidOperationException("WeChat platform public key is not configured."); + } + + if (!Path.IsPathRooted(keyPath)) + { + keyPath = Path.Combine(AppContext.BaseDirectory, keyPath); + } + + if (!File.Exists(keyPath)) + { + throw new InvalidOperationException($"WeChat platform public key file was not found: {keyPath}"); + } + + return File.ReadAllText(keyPath); + } + + private string DecryptNotificationResource(PaymentNotificationResource resource) + { + if (!string.Equals(resource.Algorithm, "AEAD_AES_256_GCM", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Unsupported WeChat notification algorithm: {resource.Algorithm}"); + } + + var keyBytes = Encoding.UTF8.GetBytes(_options.ApiV3Key); + if (keyBytes.Length != 32) + { + throw new InvalidOperationException("WeChat ApiV3Key must be 32 bytes."); + } + + var cipherBytes = Convert.FromBase64String(resource.Ciphertext); + var nonceBytes = Encoding.UTF8.GetBytes(resource.Nonce); + var associatedDataBytes = Encoding.UTF8.GetBytes(resource.AssociatedData ?? string.Empty); + + if (cipherBytes.Length < 16) + { + throw new InvalidOperationException("Invalid WeChat notification ciphertext."); + } + + var tagLength = 16; + var ciphertextLength = cipherBytes.Length - tagLength; + var plaintextBytes = new byte[ciphertextLength]; + var tagBytes = new byte[tagLength]; + Buffer.BlockCopy(cipherBytes, ciphertextLength, tagBytes, 0, tagLength); + + using var aesGcm = new AesGcm(keyBytes, tagLength); + aesGcm.Decrypt( + nonceBytes, + cipherBytes.AsSpan(0, ciphertextLength), + tagBytes, + plaintextBytes, + associatedDataBytes); + + return Encoding.UTF8.GetString(plaintextBytes); + } + + private static bool TryExtractOrderId(string? attach, out int orderId) + { + orderId = 0; + + if (string.IsNullOrWhiteSpace(attach)) + { + return false; + } + + const string prefix = "orderId="; + if (!attach.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return int.TryParse(attach[prefix.Length..], out orderId); + } + + private static int ConvertToFen(decimal amount) + { + return decimal.ToInt32(decimal.Round(amount * 100m, 0, MidpointRounding.AwayFromZero)); + } + + private static string BuildOutTradeNo(int orderId) + { + return $"PW{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{orderId:D6}"; + } + + private static T? TryDeserialize(string json) + { + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch + { + return default; + } + } + + private sealed class NativePayRequest + { + [JsonPropertyName("appid")] + public string AppId { get; init; } = string.Empty; + + [JsonPropertyName("mchid")] + public string MerchantId { get; init; } = string.Empty; + + [JsonPropertyName("description")] + public string Description { get; init; } = string.Empty; + + [JsonPropertyName("out_trade_no")] + public string OutTradeNo { get; init; } = string.Empty; + + [JsonPropertyName("notify_url")] + public string NotifyUrl { get; init; } = string.Empty; + + [JsonPropertyName("time_expire")] + public string? TimeExpire { get; init; } + + [JsonPropertyName("attach")] + public string? Attach { get; init; } + + [JsonPropertyName("amount")] + public NativePayAmount Amount { get; init; } = new(); + } + + private sealed class NativePayAmount + { + [JsonPropertyName("total")] + public int Total { get; init; } + + [JsonPropertyName("currency")] + public string Currency { get; init; } = "CNY"; + } + + private sealed class NativePayResponse + { + [JsonPropertyName("code_url")] + public string CodeUrl { get; init; } = string.Empty; + } + + private sealed class QueryOrderResponse + { + [JsonPropertyName("out_trade_no")] + public string OutTradeNo { get; init; } = string.Empty; + + [JsonPropertyName("trade_state")] + public string TradeState { get; init; } = string.Empty; + + [JsonPropertyName("transaction_id")] + public string TransactionId { get; init; } = string.Empty; + + [JsonPropertyName("attach")] + public string Attach { get; init; } = string.Empty; + + [JsonPropertyName("success_time")] + public string SuccessTime { get; init; } = string.Empty; + } + + private sealed class WeChatErrorResponse + { + [JsonPropertyName("code")] + public string Code { get; init; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; init; } = string.Empty; + } + + private sealed class PaymentNotificationEnvelope + { + [JsonPropertyName("resource")] + public PaymentNotificationResource? Resource { get; init; } + } + + private sealed class PaymentNotificationResource + { + [JsonPropertyName("algorithm")] + public string Algorithm { get; init; } = string.Empty; + + [JsonPropertyName("ciphertext")] + public string Ciphertext { get; init; } = string.Empty; + + [JsonPropertyName("nonce")] + public string Nonce { get; init; } = string.Empty; + + [JsonPropertyName("associated_data")] + public string? AssociatedData { get; init; } + } + + private sealed class PaymentTransaction + { + [JsonPropertyName("out_trade_no")] + public string OutTradeNo { get; init; } = string.Empty; + + [JsonPropertyName("trade_state")] + public string TradeState { get; init; } = string.Empty; + + [JsonPropertyName("transaction_id")] + public string TransactionId { get; init; } = string.Empty; + + [JsonPropertyName("attach")] + public string Attach { get; init; } = string.Empty; + } +} diff --git a/PetWash.Api/appsettings.json b/PetWash.Api/appsettings.json index a2d9c5b..cc036df 100644 --- a/PetWash.Api/appsettings.json +++ b/PetWash.Api/appsettings.json @@ -10,5 +10,19 @@ "DefaultConnection": "Data Source=petwash.db", "MySqlConnection": "Server=101.132.182.216;Database=petwash;User=sc_root;Password=Shsc#$@2024#@!;Port=3306;CharSet=utf8mb4;" }, - "DatabaseProvider": "Sqlite" + "DatabaseProvider": "Sqlite", + "WeChatPay": { + "AppId": "", + "MerchantId": "1107066208", + "CertificateSerialNumber": "", + "PrivateKeyPath": "", + "PrivateKeyPem": "", + "NotifyUrl": "", + "ApiV3Key": "", + "PlatformPublicKeyPath": "", + "PlatformPublicKeyPem": "", + "PlatformPublicKeySerial": "", + "Currency": "CNY", + "OrderExpireMinutes": 5 + } } diff --git a/PetWashControl/Models/CreateOrderResponse.cs b/PetWashControl/Models/CreateOrderResponse.cs new file mode 100644 index 0000000..fbf8134 --- /dev/null +++ b/PetWashControl/Models/CreateOrderResponse.cs @@ -0,0 +1,9 @@ +namespace PetWashControl.Models; + +public sealed class CreateOrderResponse +{ + public Order? Order { get; set; } + public string CodeUrl { get; set; } = string.Empty; + public string OutTradeNo { get; set; } = string.Empty; + public DateTimeOffset? ExpiresAt { get; set; } +} diff --git a/PetWashControl/Models/PaymentStatusResponse.cs b/PetWashControl/Models/PaymentStatusResponse.cs new file mode 100644 index 0000000..bdec759 --- /dev/null +++ b/PetWashControl/Models/PaymentStatusResponse.cs @@ -0,0 +1,11 @@ +namespace PetWashControl.Models; + +public sealed class PaymentStatusResponse +{ + public Order? Order { get; set; } + public bool IsPaid { get; set; } + public string TradeState { get; set; } = string.Empty; + public string OutTradeNo { get; set; } = string.Empty; + public string TransactionId { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} diff --git a/PetWashControl/PetWashControl.csproj b/PetWashControl/PetWashControl.csproj index fa4cbde..ccb50a5 100644 --- a/PetWashControl/PetWashControl.csproj +++ b/PetWashControl/PetWashControl.csproj @@ -13,6 +13,7 @@ + diff --git a/PetWashControl/Services/ApiService.cs b/PetWashControl/Services/ApiService.cs index d3b38ec..3f98162 100644 --- a/PetWashControl/Services/ApiService.cs +++ b/PetWashControl/Services/ApiService.cs @@ -32,13 +32,13 @@ public class ApiService } } - public async Task CreateOrderAsync(int packageId) + public async Task CreateOrderAsync(int packageId) { try { var response = await _httpClient.PostAsJsonAsync("api/orders", new { packageId }); response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(); + return await response.Content.ReadFromJsonAsync(); } catch (HttpRequestException ex) { @@ -60,6 +60,19 @@ public class ApiService } } + public async Task GetPaymentStatusAsync(int orderId, string outTradeNo) + { + try + { + return await _httpClient.GetFromJsonAsync( + $"api/orders/{orderId}/payment-status?outTradeNo={Uri.EscapeDataString(outTradeNo)}"); + } + catch (HttpRequestException ex) + { + throw new Exception($"获取支付状态失败: {ex.Message}", ex); + } + } + public async Task GetOrderAsync(int orderId) { try diff --git a/PetWashControl/ViewModels/MainViewModel.cs b/PetWashControl/ViewModels/MainViewModel.cs index 92c574a..d2d572b 100644 --- a/PetWashControl/ViewModels/MainViewModel.cs +++ b/PetWashControl/ViewModels/MainViewModel.cs @@ -2,9 +2,14 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using PetWashControl.Models; using PetWashControl.Services; +using QRCoder; using System.Collections.ObjectModel; +using System.IO; using System.Text.Json; using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; namespace PetWashControl.ViewModels; @@ -39,6 +44,27 @@ public partial class MainViewModel : ObservableObject [ObservableProperty] private Order? _currentOrder; + [ObservableProperty] + private ImageSource? _paymentQrCodeImage; + + [ObservableProperty] + private string _paymentCodeUrl = ""; + + [ObservableProperty] + private string _paymentOutTradeNo = ""; + + [ObservableProperty] + private DateTimeOffset? _paymentExpiresAt; + + [ObservableProperty] + private string _paymentCountdownText = ""; + + [ObservableProperty] + private string _paymentStatusText = "等待扫码支付"; + + [ObservableProperty] + private bool _isPaymentExpired; + [ObservableProperty] private string _statusMessage = "系统就绪"; @@ -146,8 +172,12 @@ public partial class MainViewModel : ObservableObject private readonly System.Timers.Timer _carouselTimer; private readonly System.Timers.Timer _clockTimer; private readonly System.Timers.Timer _liquidLevelTimer; + private readonly DispatcherTimer _paymentStatusTimer; + private readonly DispatcherTimer _paymentCountdownTimer; private readonly string[] _carouselImages = { "/Images/dog.png", "/Images/dog1.png", "/Images/dog2.png" }; private int _currentImageIndex = 0; + private bool _isPaymentPolling; + private bool _isCheckingPaymentStatus; public MainViewModel() { @@ -217,6 +247,18 @@ public partial class MainViewModel : ObservableObject } }; _liquidLevelTimer.Start(); + + _paymentStatusTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(Math.Max(1, _config.PaymentCheckIntervalSeconds)) + }; + _paymentStatusTimer.Tick += async (s, e) => await PollPaymentStatusAsync(); + + _paymentCountdownTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1) + }; + _paymentCountdownTimer.Tick += async (s, e) => await RefreshPaymentCountdownAsync(); } private void InitializeWashSteps() @@ -654,16 +696,33 @@ public partial class MainViewModel : ObservableObject try { // 创建订单 - CurrentOrder = await _apiService.CreateOrderAsync(package.Id); + var createOrderResponse = await _apiService.CreateOrderAsync(package.Id); + CurrentOrder = createOrderResponse?.Order; + PaymentCodeUrl = createOrderResponse?.CodeUrl ?? ""; + PaymentOutTradeNo = createOrderResponse?.OutTradeNo ?? ""; + PaymentExpiresAt = createOrderResponse?.ExpiresAt; + IsPaymentExpired = false; + PaymentStatusText = "等待扫码支付"; + RefreshPaymentCountdown(); + PaymentQrCodeImage = string.IsNullOrWhiteSpace(PaymentCodeUrl) + ? null + : BuildPaymentQrCodeImage(PaymentCodeUrl); + + if (CurrentOrder == null || PaymentQrCodeImage == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo)) + { + throw new InvalidOperationException("Payment QR code was not returned by the server."); + } StatusMessage = $"订单创建成功,请扫码支付 ¥{package.Price}"; _logger.LogInfo($"订单创建成功,订单ID: {CurrentOrder?.Id}"); // 切换到二维码支付界面 CurrentView = "QRCode"; ViewChanged?.Invoke("QRCode"); + StartPaymentStatusPolling(); } catch (Exception ex) { + StopPaymentStatusPolling(); _logger.LogError("创建订单失败", ex); StatusMessage = $"创建订单失败: {ex.Message}"; MessageBox.Show($"创建订单失败\n\n{ex.Message}", "错误", @@ -672,14 +731,10 @@ public partial class MainViewModel : ObservableObject } [RelayCommand] - private void CancelPayment() + private async Task CancelPayment() { _logger.LogInfo("取消支付"); - CurrentOrder = null; - SelectedPackage = null; - CurrentView = "Payment"; - ViewChanged?.Invoke("Payment"); - StatusMessage = "已取消支付,请重新选择套餐"; + await CancelCurrentPaymentAsync("已取消支付,请重新选择套餐"); } [RelayCommand] @@ -694,7 +749,22 @@ public partial class MainViewModel : ObservableObject try { _logger.LogInfo($"创建订单,套餐ID: {SelectedPackage.Id}"); - CurrentOrder = await _apiService.CreateOrderAsync(SelectedPackage.Id); + var createOrderResponse = await _apiService.CreateOrderAsync(SelectedPackage.Id); + CurrentOrder = createOrderResponse?.Order; + PaymentCodeUrl = createOrderResponse?.CodeUrl ?? ""; + PaymentOutTradeNo = createOrderResponse?.OutTradeNo ?? ""; + PaymentExpiresAt = createOrderResponse?.ExpiresAt; + IsPaymentExpired = false; + PaymentStatusText = "等待扫码支付"; + RefreshPaymentCountdown(); + PaymentQrCodeImage = string.IsNullOrWhiteSpace(PaymentCodeUrl) + ? null + : BuildPaymentQrCodeImage(PaymentCodeUrl); + + if (CurrentOrder == null || PaymentQrCodeImage == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo)) + { + throw new InvalidOperationException("Payment QR code was not returned by the server."); + } StatusMessage = $"订单创建成功,订单号: {CurrentOrder?.Id},请支付 ¥{SelectedPackage.Price}"; _logger.LogInfo($"订单创建成功,订单ID: {CurrentOrder?.Id}"); } @@ -719,6 +789,7 @@ public partial class MainViewModel : ObservableObject try { _logger.LogInfo($"模拟支付,订单ID: {CurrentOrder.Id}"); + StopPaymentStatusPolling(); // 模拟支付处理延迟 StatusMessage = "正在处理支付..."; @@ -726,17 +797,7 @@ public partial class MainViewModel : ObservableObject // 确认支付 CurrentOrder = await _apiService.ConfirmPaymentAsync(CurrentOrder.Id); - StatusMessage = "支付成功!设备门已打开,请将宠物放入"; - IsDoorOpen = true; - _logger.LogInfo("支付成功,门已打开"); - - // 显示支付成功提示 - MessageBox.Show("支付成功!\n\n设备门已自动打开\n请将宠物放入设备后关闭门开始洗护", - "支付成功", MessageBoxButton.OK, MessageBoxImage.Information); - - // 返回待机界面,显示关门按钮 - CurrentView = "Idle"; - ViewChanged?.Invoke("Idle"); + await HandlePaymentSuccessAsync(); } catch (Exception ex) { @@ -747,6 +808,212 @@ public partial class MainViewModel : ObservableObject } } + private static BitmapImage BuildPaymentQrCodeImage(string codeUrl) + { + using var generator = new QRCodeGenerator(); + using var qrCodeData = generator.CreateQrCode(codeUrl, QRCodeGenerator.ECCLevel.Q); + var qrCode = new PngByteQRCode(qrCodeData); + var pngBytes = qrCode.GetGraphic(20); + + using var stream = new MemoryStream(pngBytes); + var image = new BitmapImage(); + image.BeginInit(); + image.CacheOption = BitmapCacheOption.OnLoad; + image.StreamSource = stream; + image.EndInit(); + image.Freeze(); + + return image; + } + + private void StartPaymentStatusPolling() + { + StopPaymentStatusPolling(); + + if (CurrentOrder == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo)) + { + return; + } + + _isPaymentPolling = true; + _paymentStatusTimer.Start(); + _paymentCountdownTimer.Start(); + } + + private void StopPaymentStatusPolling() + { + _isPaymentPolling = false; + _paymentStatusTimer.Stop(); + _paymentCountdownTimer.Stop(); + _isCheckingPaymentStatus = false; + } + + private void RefreshPaymentCountdown() + { + if (PaymentExpiresAt == null) + { + PaymentCountdownText = ""; + return; + } + + var remaining = PaymentExpiresAt.Value - DateTimeOffset.Now; + if (remaining <= TimeSpan.Zero) + { + PaymentCountdownText = "00:00"; + return; + } + + PaymentCountdownText = remaining.ToString(@"mm\:ss"); + } + + private async Task RefreshPaymentCountdownAsync() + { + RefreshPaymentCountdown(); + + if (IsPaymentExpired || PaymentExpiresAt == null || CurrentOrder == null || CurrentOrder.IsPaid) + { + return; + } + + if (PaymentExpiresAt.Value <= DateTimeOffset.Now) + { + await HandlePaymentExpiredAsync(); + } + } + + private async Task PollPaymentStatusAsync() + { + if (_isCheckingPaymentStatus) + { + return; + } + + if (!_isPaymentPolling || CurrentOrder == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo)) + { + StopPaymentStatusPolling(); + return; + } + + try + { + _isCheckingPaymentStatus = true; + PaymentStatusText = "正在确认支付结果..."; + var paymentStatus = await _apiService.GetPaymentStatusAsync(CurrentOrder.Id, PaymentOutTradeNo); + if (paymentStatus?.Order != null) + { + CurrentOrder = paymentStatus.Order; + } + + if (paymentStatus?.IsPaid == true) + { + await HandlePaymentSuccessAsync(); + } + else if (!IsPaymentExpired) + { + PaymentStatusText = "等待扫码支付"; + } + } + catch (Exception ex) + { + _logger.LogError("轮询支付状态失败", ex); + if (!IsPaymentExpired) + { + PaymentStatusText = "支付状态查询异常"; + } + } + finally + { + _isCheckingPaymentStatus = false; + } + } + + private Task HandlePaymentSuccessAsync() + { + StopPaymentStatusPolling(); + + if (CurrentOrder == null) + { + return Task.CompletedTask; + } + + IsPaymentExpired = false; + PaymentStatusText = "支付成功"; + PaymentCountdownText = ""; + PaymentExpiresAt = null; + StatusMessage = "支付成功!设备门已打开,请将宠物放入"; + IsDoorOpen = true; + _logger.LogInfo("支付成功,门已打开"); + + MessageBox.Show("支付成功!\n\n设备门已自动打开\n请将宠物放入设备后关闭门开始洗护", + "支付成功", MessageBoxButton.OK, MessageBoxImage.Information); + + CurrentView = "Idle"; + ViewChanged?.Invoke("Idle"); + return Task.CompletedTask; + } + + private async Task HandlePaymentExpiredAsync() + { + if (IsPaymentExpired) + { + return; + } + + IsPaymentExpired = true; + StopPaymentStatusPolling(); + PaymentStatusText = "二维码已超时,请重新下单"; + PaymentCountdownText = "00:00"; + PaymentQrCodeImage = null; + StatusMessage = "支付超时,请重新选择套餐"; + + if (CurrentOrder != null && !CurrentOrder.IsPaid) + { + try + { + CurrentOrder = await _apiService.UpdateOrderStatusAsync(CurrentOrder.Id, OrderStatus.Cancelled); + } + catch (Exception ex) + { + _logger.LogError("支付超时后取消订单失败", ex); + } + } + } + + private async Task CancelCurrentPaymentAsync(string statusMessage) + { + StopPaymentStatusPolling(); + + if (CurrentOrder != null && !CurrentOrder.IsPaid) + { + try + { + CurrentOrder = await _apiService.UpdateOrderStatusAsync(CurrentOrder.Id, OrderStatus.Cancelled); + } + catch (Exception ex) + { + _logger.LogError("取消支付时更新订单状态失败", ex); + } + } + + ResetPaymentSession(); + CurrentView = "Payment"; + ViewChanged?.Invoke("Payment"); + StatusMessage = statusMessage; + } + + private void ResetPaymentSession() + { + CurrentOrder = null; + SelectedPackage = null; + PaymentCodeUrl = ""; + PaymentOutTradeNo = ""; + PaymentExpiresAt = null; + PaymentCountdownText = ""; + PaymentStatusText = "等待扫码支付"; + IsPaymentExpired = false; + PaymentQrCodeImage = null; + } + [RelayCommand] private async Task CloseDoorAsync() { diff --git a/PetWashControl/Views/MainWindow.xaml b/PetWashControl/Views/MainWindow.xaml index 7a17411..a37a6d7 100644 --- a/PetWashControl/Views/MainWindow.xaml +++ b/PetWashControl/Views/MainWindow.xaml @@ -813,20 +813,38 @@ - + + + + + + + - - Date: Wed, 18 Mar 2026 13:53:44 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PetWash.Api/Controllers/PackagesController.cs | 72 +++- PetWash.Api/Data/PetWashDbContext.cs | 8 +- PetWash.Api/Models/Package.cs | 10 + PetWash.Api/Models/PackageCatalog.cs | 62 +++ PetWash.Api/Program.cs | 124 +++++- PetWash.Api/appsettings.json | 6 +- PetWashControl/Models/Package.cs | 45 ++ PetWashControl/PetWashControl.csproj | 6 + PetWashControl/Services/AdminAccessService.cs | 70 ++++ PetWashControl/Services/ApiService.cs | 26 ++ .../Services/ConfigurationService.cs | 174 +++++++- PetWashControl/ViewModels/MainViewModel.cs | 395 ++++++++++++++++-- PetWashControl/Views/AdminLoginWindow.xaml | 97 +++++ PetWashControl/Views/AdminLoginWindow.xaml.cs | 69 +++ PetWashControl/Views/MainWindow.xaml | 119 +++++- PetWashControl/Views/MainWindow.xaml.cs | 83 +++- .../Views/PackageManagementWindow.xaml | 350 ++++++++++++++++ .../Views/PackageManagementWindow.xaml.cs | 18 + PetWashControl/appsettings.json | 29 ++ init-database.sql | 47 ++- 20 files changed, 1700 insertions(+), 110 deletions(-) create mode 100644 PetWash.Api/Models/PackageCatalog.cs create mode 100644 PetWashControl/Services/AdminAccessService.cs create mode 100644 PetWashControl/Views/AdminLoginWindow.xaml create mode 100644 PetWashControl/Views/AdminLoginWindow.xaml.cs create mode 100644 PetWashControl/Views/PackageManagementWindow.xaml create mode 100644 PetWashControl/Views/PackageManagementWindow.xaml.cs create mode 100644 PetWashControl/appsettings.json diff --git a/PetWash.Api/Controllers/PackagesController.cs b/PetWash.Api/Controllers/PackagesController.cs index a5236e5..fb9a1b7 100644 --- a/PetWash.Api/Controllers/PackagesController.cs +++ b/PetWash.Api/Controllers/PackagesController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PetWash.Api.Data; +using PetWash.Api.Models; namespace PetWash.Api.Controllers; @@ -18,7 +19,9 @@ public class PackagesController : ControllerBase [HttpGet] public async Task GetPackages() { - var packages = await _context.Packages.ToListAsync(); + var packages = await _context.Packages + .OrderBy(x => x.Id) + .ToListAsync(); return Ok(packages); } @@ -31,4 +34,71 @@ public class PackagesController : ControllerBase return Ok(package); } + + [HttpPut("{id}")] + public async Task UpdatePackage(int id, [FromBody] UpdatePackageRequest request) + { + var package = await _context.Packages.FindAsync(id); + if (package == null) + { + return NotFound(); + } + + if (request.Price <= 0) + { + return BadRequest("套餐金额必须大于 0。"); + } + + if (request.FirstSprayWaterTime <= 0 || + request.AfterShampoo1SprayTime <= 0 || + request.AfterShampoo2SprayTime <= 0 || + request.AfterShampoo3SprayTime <= 0 || + request.SprayShampoo1Time <= 0 || + request.SprayShampoo2Time <= 0 || + request.SprayShampoo3Time <= 0 || + request.HotAirTime <= 0 || + request.ColdAirTime <= 0 || + request.UvSterilizationTime <= 0) + { + return BadRequest("套餐流程时间必须全部大于 0。"); + } + + package.Price = request.Price; + package.FirstSprayWaterTime = request.FirstSprayWaterTime; + package.AfterShampoo1SprayTime = request.AfterShampoo1SprayTime; + package.AfterShampoo2SprayTime = request.AfterShampoo2SprayTime; + package.AfterShampoo3SprayTime = request.AfterShampoo3SprayTime; + package.SprayShampoo1Time = request.SprayShampoo1Time; + package.SprayShampoo2Time = request.SprayShampoo2Time; + package.SprayShampoo3Time = request.SprayShampoo3Time; + package.HotAirTime = request.HotAirTime; + package.ColdAirTime = request.ColdAirTime; + package.UvSterilizationTime = request.UvSterilizationTime; + package.DurationMinutes = request.FirstSprayWaterTime + + request.AfterShampoo1SprayTime + + request.AfterShampoo2SprayTime + + request.AfterShampoo3SprayTime + + request.SprayShampoo1Time + + request.SprayShampoo2Time + + request.SprayShampoo3Time + + request.HotAirTime + + request.ColdAirTime + + request.UvSterilizationTime; + + await _context.SaveChangesAsync(); + return Ok(package); + } } + +public record UpdatePackageRequest( + decimal Price, + int FirstSprayWaterTime, + int AfterShampoo1SprayTime, + int AfterShampoo2SprayTime, + int AfterShampoo3SprayTime, + int SprayShampoo1Time, + int SprayShampoo2Time, + int SprayShampoo3Time, + int HotAirTime, + int ColdAirTime, + int UvSterilizationTime); diff --git a/PetWash.Api/Data/PetWashDbContext.cs b/PetWash.Api/Data/PetWashDbContext.cs index a49d399..3a862ba 100644 --- a/PetWash.Api/Data/PetWashDbContext.cs +++ b/PetWash.Api/Data/PetWashDbContext.cs @@ -13,12 +13,6 @@ public class PetWashDbContext : DbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - - // 初始化套餐数据 - modelBuilder.Entity().HasData( - new Package { Id = 1, Name = "套餐1", Price = 50, DurationMinutes = 38, Description = "适用于小型犬" }, - new Package { Id = 2, Name = "套餐2", Price = 80, DurationMinutes = 48, Description = "适用于中型犬" }, - new Package { Id = 3, Name = "套餐3", Price = 120, DurationMinutes = 60, Description = "适用于大型犬" } - ); + modelBuilder.Entity().HasData(PackageCatalog.DefaultPackages.ToArray()); } } diff --git a/PetWash.Api/Models/Package.cs b/PetWash.Api/Models/Package.cs index ee6534f..681e145 100644 --- a/PetWash.Api/Models/Package.cs +++ b/PetWash.Api/Models/Package.cs @@ -7,4 +7,14 @@ public class Package public decimal Price { get; set; } public int DurationMinutes { get; set; } public string Description { get; set; } = string.Empty; + public int FirstSprayWaterTime { get; set; } + public int AfterShampoo1SprayTime { get; set; } + public int AfterShampoo2SprayTime { get; set; } + public int AfterShampoo3SprayTime { get; set; } + public int SprayShampoo1Time { get; set; } + public int SprayShampoo2Time { get; set; } + public int SprayShampoo3Time { get; set; } + public int HotAirTime { get; set; } + public int ColdAirTime { get; set; } + public int UvSterilizationTime { get; set; } } diff --git a/PetWash.Api/Models/PackageCatalog.cs b/PetWash.Api/Models/PackageCatalog.cs new file mode 100644 index 0000000..d020ec5 --- /dev/null +++ b/PetWash.Api/Models/PackageCatalog.cs @@ -0,0 +1,62 @@ +namespace PetWash.Api.Models; + +public static class PackageCatalog +{ + public static IReadOnlyList DefaultPackages { get; } = + [ + new Package + { + Id = 1, + Name = "小型犬套餐", + Price = 0.01m, + DurationMinutes = 20, + Description = "适用于小型犬,洗护流程较短", + FirstSprayWaterTime = 2, + AfterShampoo1SprayTime = 2, + AfterShampoo2SprayTime = 2, + AfterShampoo3SprayTime = 2, + SprayShampoo1Time = 1, + SprayShampoo2Time = 1, + SprayShampoo3Time = 1, + HotAirTime = 5, + ColdAirTime = 2, + UvSterilizationTime = 2 + }, + new Package + { + Id = 2, + Name = "中型犬套餐", + Price = 0.02m, + DurationMinutes = 26, + Description = "适用于中型犬,洗护流程适中", + FirstSprayWaterTime = 3, + AfterShampoo1SprayTime = 3, + AfterShampoo2SprayTime = 3, + AfterShampoo3SprayTime = 3, + SprayShampoo1Time = 1, + SprayShampoo2Time = 1, + SprayShampoo3Time = 1, + HotAirTime = 6, + ColdAirTime = 3, + UvSterilizationTime = 2 + }, + new Package + { + Id = 3, + Name = "大型犬套餐", + Price = 0.03m, + DurationMinutes = 37, + Description = "适用于大型犬,洗护流程更长", + FirstSprayWaterTime = 4, + AfterShampoo1SprayTime = 4, + AfterShampoo2SprayTime = 4, + AfterShampoo3SprayTime = 4, + SprayShampoo1Time = 2, + SprayShampoo2Time = 2, + SprayShampoo3Time = 2, + HotAirTime = 8, + ColdAirTime = 4, + UvSterilizationTime = 3 + } + ]; +} diff --git a/PetWash.Api/Program.cs b/PetWash.Api/Program.cs index 5e10e48..d81929d 100644 --- a/PetWash.Api/Program.cs +++ b/PetWash.Api/Program.cs @@ -1,12 +1,14 @@ +using System.Data; +using System.Data.Common; using Microsoft.EntityFrameworkCore; using PetWash.Api.Data; +using PetWash.Api.Models; using PetWash.Api.Services; var builder = WebApplication.CreateBuilder(args); -// 配置数据库(支持 SQLite 和 MySQL) var dbProvider = builder.Configuration.GetValue("DatabaseProvider") ?? "Sqlite"; -var connectionString = dbProvider == "MySql" +var connectionString = dbProvider == "MySql" ? builder.Configuration.GetConnectionString("MySqlConnection") : builder.Configuration.GetConnectionString("DefaultConnection"); @@ -23,7 +25,6 @@ builder.Services.AddDbContext(options => } }); -// 添加服务 builder.Services.AddSingleton(); builder.Services.AddHostedService(provider => provider.GetRequiredService()); builder.Services.AddScoped(); @@ -34,7 +35,6 @@ builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15); }); -// 添加CORS builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => @@ -51,36 +51,79 @@ builder.Services.AddSwaggerGen(); var app = builder.Build(); -// 初始化数据库 using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService>(); - + try { - logger.LogInformation("开始初始化数据库..."); - - // 确保数据库已创建 + logger.LogInformation("Starting database initialization."); + var created = db.Database.EnsureCreated(); - if (created) { - logger.LogInformation("数据库创建成功,种子数据已插入"); + logger.LogInformation("Database created and seed data inserted."); } else { - logger.LogInformation("数据库已存在"); + logger.LogInformation("Database already exists."); } + + EnsurePackageTimingColumns(db, logger); + + foreach (var package in PackageCatalog.DefaultPackages) + { + var existingPackage = db.Packages.FirstOrDefault(x => x.Id == package.Id); + if (existingPackage is null) + { + db.Packages.Add(new Package + { + Id = package.Id, + Name = package.Name, + Price = package.Price, + DurationMinutes = package.DurationMinutes, + Description = package.Description, + FirstSprayWaterTime = package.FirstSprayWaterTime, + AfterShampoo1SprayTime = package.AfterShampoo1SprayTime, + AfterShampoo2SprayTime = package.AfterShampoo2SprayTime, + AfterShampoo3SprayTime = package.AfterShampoo3SprayTime, + SprayShampoo1Time = package.SprayShampoo1Time, + SprayShampoo2Time = package.SprayShampoo2Time, + SprayShampoo3Time = package.SprayShampoo3Time, + HotAirTime = package.HotAirTime, + ColdAirTime = package.ColdAirTime, + UvSterilizationTime = package.UvSterilizationTime + }); + } + else + { + existingPackage.Name = package.Name; + existingPackage.Price = package.Price; + existingPackage.DurationMinutes = package.DurationMinutes; + existingPackage.Description = package.Description; + existingPackage.FirstSprayWaterTime = package.FirstSprayWaterTime; + existingPackage.AfterShampoo1SprayTime = package.AfterShampoo1SprayTime; + existingPackage.AfterShampoo2SprayTime = package.AfterShampoo2SprayTime; + existingPackage.AfterShampoo3SprayTime = package.AfterShampoo3SprayTime; + existingPackage.SprayShampoo1Time = package.SprayShampoo1Time; + existingPackage.SprayShampoo2Time = package.SprayShampoo2Time; + existingPackage.SprayShampoo3Time = package.SprayShampoo3Time; + existingPackage.HotAirTime = package.HotAirTime; + existingPackage.ColdAirTime = package.ColdAirTime; + existingPackage.UvSterilizationTime = package.UvSterilizationTime; + } + } + + db.SaveChanges(); } catch (Exception ex) { - logger.LogError(ex, "数据库初始化失败"); + logger.LogError(ex, "Database initialization failed."); throw; } } -// 启用 Swagger(所有环境) app.UseSwagger(); app.UseSwaggerUI(); @@ -90,3 +133,56 @@ app.UseAuthorization(); app.MapControllers(); app.Run(); + +static void EnsurePackageTimingColumns(PetWashDbContext db, ILogger logger) +{ + var providerName = db.Database.ProviderName ?? string.Empty; + var isSqlite = providerName.Contains("Sqlite", StringComparison.OrdinalIgnoreCase); + var missingColumns = GetMissingPackageTimingColumns(db.Database.GetDbConnection(), isSqlite); + + foreach (var column in missingColumns) + { + var sql = isSqlite + ? $"ALTER TABLE Packages ADD COLUMN {column.Name} INTEGER NOT NULL DEFAULT {column.DefaultValue};" + : $"ALTER TABLE Packages ADD COLUMN {column.Name} INT NOT NULL DEFAULT {column.DefaultValue};"; + + db.Database.ExecuteSqlRaw(sql); + logger.LogInformation("Added missing package timing column {ColumnName}", column.Name); + } +} + +static List<(string Name, int DefaultValue)> GetMissingPackageTimingColumns(DbConnection connection, bool isSqlite) +{ + var expectedColumns = new List<(string Name, int DefaultValue)> + { + ("FirstSprayWaterTime", 2), + ("AfterShampoo1SprayTime", 2), + ("AfterShampoo2SprayTime", 2), + ("AfterShampoo3SprayTime", 2), + ("SprayShampoo1Time", 1), + ("SprayShampoo2Time", 1), + ("SprayShampoo3Time", 1), + ("HotAirTime", 5), + ("ColdAirTime", 2), + ("UvSterilizationTime", 2) + }; + + if (connection.State != ConnectionState.Open) + { + connection.Open(); + } + + using var command = connection.CreateCommand(); + command.CommandText = isSqlite ? "PRAGMA table_info(Packages);" : "SHOW COLUMNS FROM Packages;"; + + using var reader = command.ExecuteReader(); + var existingColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + while (reader.Read()) + { + existingColumns.Add(reader.GetString(isSqlite ? 1 : 0)); + } + + return expectedColumns + .Where(column => !existingColumns.Contains(column.Name)) + .ToList(); +} diff --git a/PetWash.Api/appsettings.json b/PetWash.Api/appsettings.json index cc036df..5d86962 100644 --- a/PetWash.Api/appsettings.json +++ b/PetWash.Api/appsettings.json @@ -12,13 +12,13 @@ }, "DatabaseProvider": "Sqlite", "WeChatPay": { - "AppId": "", + "AppId": "wxa27a3e3cfce7ae19", "MerchantId": "1107066208", - "CertificateSerialNumber": "", + "CertificateSerialNumber": "3243AE8427384A692FBAA92C5EC5887BEF1988FD", "PrivateKeyPath": "", "PrivateKeyPem": "", "NotifyUrl": "", - "ApiV3Key": "", + "ApiV3Key": "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDMlj1LkO9Cfg3LWBYpe9GBn7vWgLE6kqEG1ohaxbaPxA6OwuGn0XQZfRBbJmncSXGLYahQ7T0OvFBIp8SyYm6q9kol8c9naxd+KxjMrx/qSWqwEJ76meBNK6LBYBVFTobg47cexpyR1TOZK0EFBGJQU2yQ1nsuQczVvq+WaSn4+kVENWf+o2g2nFS1VXNBIjL0/C8vXbz/0Y8k6ecH5mbmy/t+YR6X4TsiIAzIxIcfMMNhVCwqKLsu3D20N0ViYbKToHWIXi8wS8dyruHqQ1lZVJV/fF7pdI36HFI94enksCZrDb1LVFjL+4ccE04MJLIEZSH73RrOFkLaRzn8pwBbAgMBAAECggEAY7kD7baa+XVKMgkg3F2vVJjQzZDzUpKwjQ27b0uaXl95nRrfNZcCGX59n4CM70SZZRBYJAJP1cP", "PlatformPublicKeyPath": "", "PlatformPublicKeyPem": "", "PlatformPublicKeySerial": "", diff --git a/PetWashControl/Models/Package.cs b/PetWashControl/Models/Package.cs index 64f7b56..3a1da8e 100644 --- a/PetWashControl/Models/Package.cs +++ b/PetWashControl/Models/Package.cs @@ -7,4 +7,49 @@ public class Package public decimal Price { get; set; } public int DurationMinutes { get; set; } public string Description { get; set; } = string.Empty; + public int FirstSprayWaterTime { get; set; } + public int AfterShampoo1SprayTime { get; set; } + public int AfterShampoo2SprayTime { get; set; } + public int AfterShampoo3SprayTime { get; set; } + public int SprayShampoo1Time { get; set; } + public int SprayShampoo2Time { get; set; } + public int SprayShampoo3Time { get; set; } + public int HotAirTime { get; set; } + public int ColdAirTime { get; set; } + public int UvSterilizationTime { get; set; } + + public string DisplayPrice => Price.ToString("F2"); + public string DisplayDuration => $"{DurationMinutes} 分钟"; + public string DisplaySummary => $"{Description} | 总时长 {DurationMinutes} 分钟"; + public string DisplayTag => Id switch + { + 1 => "小型犬", + 2 => "中型犬", + 3 => "大型犬", + _ => "宠物套餐" + }; + + public string CardBackground => Id switch + { + 1 => "#2E7D32", + 2 => "#1565C0", + 3 => "#6A1B9A", + _ => "#D84315" + }; + + public string CardDetailBackground => Id switch + { + 1 => "#1B5E20", + 2 => "#0D47A1", + 3 => "#4A148C", + _ => "#BF360C" + }; + + public string CardPriceBackground => Id switch + { + 1 => "#43A047", + 2 => "#1E88E5", + 3 => "#8E24AA", + _ => "#FF6F00" + }; } diff --git a/PetWashControl/PetWashControl.csproj b/PetWashControl/PetWashControl.csproj index ccb50a5..67d91f1 100644 --- a/PetWashControl/PetWashControl.csproj +++ b/PetWashControl/PetWashControl.csproj @@ -27,4 +27,10 @@ + + + PreserveNewest + + + diff --git a/PetWashControl/Services/AdminAccessService.cs b/PetWashControl/Services/AdminAccessService.cs new file mode 100644 index 0000000..9affaa9 --- /dev/null +++ b/PetWashControl/Services/AdminAccessService.cs @@ -0,0 +1,70 @@ +namespace PetWashControl.Services; + +public sealed class AdminAccessService +{ + private readonly ConfigurationService _config; + private int _failedAttempts; + private DateTimeOffset? _lockedUntil; + + public AdminAccessService(ConfigurationService config) + { + _config = config; + } + + public AdminAuthResult TryAuthenticate(string username, string password) + { + if (_lockedUntil is { } lockedUntil && lockedUntil > DateTimeOffset.Now) + { + return AdminAuthResult.Locked(GetLockoutMessage(lockedUntil)); + } + + if (username == _config.AdminUsername && password == _config.AdminPassword) + { + _failedAttempts = 0; + _lockedUntil = null; + return AdminAuthResult.Success(); + } + + _failedAttempts++; + var maxAttempts = Math.Max(1, _config.AdminMaxFailedAttempts); + if (_failedAttempts >= maxAttempts) + { + _failedAttempts = 0; + _lockedUntil = DateTimeOffset.Now.AddMinutes(Math.Max(1, _config.AdminLockoutMinutes)); + return AdminAuthResult.Locked(GetLockoutMessage(_lockedUntil.Value)); + } + + var remainingAttempts = maxAttempts - _failedAttempts; + return AdminAuthResult.Failed($"账号或密码错误,还可尝试 {remainingAttempts} 次。"); + } + + public string? GetCurrentLockoutMessage() + { + if (_lockedUntil is not { } lockedUntil || lockedUntil <= DateTimeOffset.Now) + { + _lockedUntil = null; + return null; + } + + return GetLockoutMessage(lockedUntil); + } + + private static string GetLockoutMessage(DateTimeOffset lockedUntil) + { + var remaining = lockedUntil - DateTimeOffset.Now; + if (remaining < TimeSpan.Zero) + { + remaining = TimeSpan.Zero; + } + + var minutes = Math.Max(0, (int)Math.Ceiling(remaining.TotalMinutes)); + return $"登录失败次数过多,请 {minutes} 分钟后再试。"; + } +} + +public readonly record struct AdminAuthResult(bool IsSuccess, bool IsLocked, string Message) +{ + public static AdminAuthResult Success() => new(true, false, string.Empty); + public static AdminAuthResult Failed(string message) => new(false, false, message); + public static AdminAuthResult Locked(string message) => new(false, true, message); +} diff --git a/PetWashControl/Services/ApiService.cs b/PetWashControl/Services/ApiService.cs index 3f98162..5572f02 100644 --- a/PetWashControl/Services/ApiService.cs +++ b/PetWashControl/Services/ApiService.cs @@ -98,4 +98,30 @@ public class ApiService throw new Exception($"更新订单状态失败: {ex.Message}", ex); } } + public async Task UpdatePackageAsync(Package package) + { + try + { + var response = await _httpClient.PutAsJsonAsync($"api/packages/{package.Id}", new + { + package.Price, + package.FirstSprayWaterTime, + package.AfterShampoo1SprayTime, + package.AfterShampoo2SprayTime, + package.AfterShampoo3SprayTime, + package.SprayShampoo1Time, + package.SprayShampoo2Time, + package.SprayShampoo3Time, + package.HotAirTime, + package.ColdAirTime, + package.UvSterilizationTime + }); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + catch (HttpRequestException ex) + { + throw new Exception($"保存套餐配置失败: {ex.Message}", ex); + } + } } diff --git a/PetWashControl/Services/ConfigurationService.cs b/PetWashControl/Services/ConfigurationService.cs index 35fca4b..ef60332 100644 --- a/PetWashControl/Services/ConfigurationService.cs +++ b/PetWashControl/Services/ConfigurationService.cs @@ -1,34 +1,176 @@ +using System.IO; +using System.Text.Json; + namespace PetWashControl.Services; public class ConfigurationService { - // https://localhost:7203/ 本地 + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + private static readonly string ConfigFilePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + public string ApiBaseUrl { get; set; } = "http://101.132.182.216:8080/"; public string MqttBrokerHost { get; set; } = "101.132.182.216"; public int MqttBrokerPort { get; set; } = 1883; public string MqttApiKey { get; set; } = "dc240ab5ec"; public string MqttId { get; set; } = "13064"; public string MqttClientId { get; set; } = "PetWashControl"; - - // Modbus TCP 配置 public string ModbusIpAddress { get; set; } = "127.0.0.1"; public int ModbusPort { get; set; } = 502; public byte ModbusSlaveId { get; set; } = 1; public int ModbusConnectTimeoutMs { get; set; } = 5000; public int ModbusReadTimeoutMs { get; set; } = 3000; - public int PaymentCheckIntervalSeconds { get; set; } = 2; public int WashSimulationSeconds { get; set; } = 10; - - // 洗护流程时间参数(单位:分钟) - public int FirstSprayWaterTime { get; set; } = 2; // 首次喷水时间 - public int AfterShampoo1SprayTime { get; set; } = 2; // 沐浴1后喷水时间 - public int AfterShampoo2SprayTime { get; set; } = 2; // 沐浴2后喷水时间 - public int AfterShampoo3SprayTime { get; set; } = 2; // 沐浴3后喷水时间 - public int SprayShampoo1Time { get; set; } = 1; // 喷沐浴露1时间 - public int SprayShampoo2Time { get; set; } = 1; // 喷沐浴露2时间 - public int SprayShampoo3Time { get; set; } = 1; // 喷沐浴露3时间 - public int ColdAirTime { get; set; } = 2; // 冷风机时间 - public int HotAirTime { get; set; } = 5; // 热风机时间 - public int UvSterilizationTime { get; set; } = 3; // 紫外线杀菌时间 + public int FirstSprayWaterTime { get; set; } = 2; + public int AfterShampoo1SprayTime { get; set; } = 2; + public int AfterShampoo2SprayTime { get; set; } = 2; + public int AfterShampoo3SprayTime { get; set; } = 2; + public int SprayShampoo1Time { get; set; } = 1; + public int SprayShampoo2Time { get; set; } = 1; + public int SprayShampoo3Time { get; set; } = 1; + public int ColdAirTime { get; set; } = 2; + public int HotAirTime { get; set; } = 5; + public int UvSterilizationTime { get; set; } = 3; + public string AdminUsername { get; set; } = "admin"; + public string AdminPassword { get; set; } = "123456"; + public int AdminMaxFailedAttempts { get; set; } = 5; + public int AdminLockoutMinutes { get; set; } = 15; + + public ConfigurationService() + { + Load(); + } + + public void Save() + { + var fileContent = JsonSerializer.Serialize(ToModel(), JsonOptions); + File.WriteAllText(ConfigFilePath, fileContent); + } + + private void Load() + { + if (!File.Exists(ConfigFilePath)) + { + Save(); + return; + } + + try + { + var fileContent = File.ReadAllText(ConfigFilePath); + var model = JsonSerializer.Deserialize(fileContent, JsonOptions); + if (model == null) + { + Save(); + return; + } + + ApplyModel(model); + } + catch + { + Save(); + } + } + + private void ApplyModel(ConfigurationModel model) + { + ApiBaseUrl = model.ApiBaseUrl; + MqttBrokerHost = model.MqttBrokerHost; + MqttBrokerPort = model.MqttBrokerPort; + MqttApiKey = model.MqttApiKey; + MqttId = model.MqttId; + MqttClientId = model.MqttClientId; + ModbusIpAddress = model.ModbusIpAddress; + ModbusPort = model.ModbusPort; + ModbusSlaveId = model.ModbusSlaveId; + ModbusConnectTimeoutMs = model.ModbusConnectTimeoutMs; + ModbusReadTimeoutMs = model.ModbusReadTimeoutMs; + PaymentCheckIntervalSeconds = model.PaymentCheckIntervalSeconds; + WashSimulationSeconds = model.WashSimulationSeconds; + FirstSprayWaterTime = model.FirstSprayWaterTime; + AfterShampoo1SprayTime = model.AfterShampoo1SprayTime; + AfterShampoo2SprayTime = model.AfterShampoo2SprayTime; + AfterShampoo3SprayTime = model.AfterShampoo3SprayTime; + SprayShampoo1Time = model.SprayShampoo1Time; + SprayShampoo2Time = model.SprayShampoo2Time; + SprayShampoo3Time = model.SprayShampoo3Time; + ColdAirTime = model.ColdAirTime; + HotAirTime = model.HotAirTime; + UvSterilizationTime = model.UvSterilizationTime; + AdminUsername = model.AdminUsername; + AdminPassword = model.AdminPassword; + AdminMaxFailedAttempts = Math.Max(1, model.AdminMaxFailedAttempts); + AdminLockoutMinutes = Math.Max(1, model.AdminLockoutMinutes); + } + + private ConfigurationModel ToModel() + { + return new ConfigurationModel + { + ApiBaseUrl = ApiBaseUrl, + MqttBrokerHost = MqttBrokerHost, + MqttBrokerPort = MqttBrokerPort, + MqttApiKey = MqttApiKey, + MqttId = MqttId, + MqttClientId = MqttClientId, + ModbusIpAddress = ModbusIpAddress, + ModbusPort = ModbusPort, + ModbusSlaveId = ModbusSlaveId, + ModbusConnectTimeoutMs = ModbusConnectTimeoutMs, + ModbusReadTimeoutMs = ModbusReadTimeoutMs, + PaymentCheckIntervalSeconds = PaymentCheckIntervalSeconds, + WashSimulationSeconds = WashSimulationSeconds, + FirstSprayWaterTime = FirstSprayWaterTime, + AfterShampoo1SprayTime = AfterShampoo1SprayTime, + AfterShampoo2SprayTime = AfterShampoo2SprayTime, + AfterShampoo3SprayTime = AfterShampoo3SprayTime, + SprayShampoo1Time = SprayShampoo1Time, + SprayShampoo2Time = SprayShampoo2Time, + SprayShampoo3Time = SprayShampoo3Time, + ColdAirTime = ColdAirTime, + HotAirTime = HotAirTime, + UvSterilizationTime = UvSterilizationTime, + AdminUsername = AdminUsername, + AdminPassword = AdminPassword, + AdminMaxFailedAttempts = Math.Max(1, AdminMaxFailedAttempts), + AdminLockoutMinutes = Math.Max(1, AdminLockoutMinutes) + }; + } + + private sealed class ConfigurationModel + { + public string ApiBaseUrl { get; set; } = "http://101.132.182.216:8080/"; + public string MqttBrokerHost { get; set; } = "101.132.182.216"; + public int MqttBrokerPort { get; set; } = 1883; + public string MqttApiKey { get; set; } = "dc240ab5ec"; + public string MqttId { get; set; } = "13064"; + public string MqttClientId { get; set; } = "PetWashControl"; + public string ModbusIpAddress { get; set; } = "127.0.0.1"; + public int ModbusPort { get; set; } = 502; + public byte ModbusSlaveId { get; set; } = 1; + public int ModbusConnectTimeoutMs { get; set; } = 5000; + public int ModbusReadTimeoutMs { get; set; } = 3000; + public int PaymentCheckIntervalSeconds { get; set; } = 2; + public int WashSimulationSeconds { get; set; } = 10; + public int FirstSprayWaterTime { get; set; } = 2; + public int AfterShampoo1SprayTime { get; set; } = 2; + public int AfterShampoo2SprayTime { get; set; } = 2; + public int AfterShampoo3SprayTime { get; set; } = 2; + public int SprayShampoo1Time { get; set; } = 1; + public int SprayShampoo2Time { get; set; } = 1; + public int SprayShampoo3Time { get; set; } = 1; + public int ColdAirTime { get; set; } = 2; + public int HotAirTime { get; set; } = 5; + public int UvSterilizationTime { get; set; } = 3; + public string AdminUsername { get; set; } = "admin"; + public string AdminPassword { get; set; } = "123456"; + public int AdminMaxFailedAttempts { get; set; } = 5; + public int AdminLockoutMinutes { get; set; } = 15; + } } diff --git a/PetWashControl/ViewModels/MainViewModel.cs b/PetWashControl/ViewModels/MainViewModel.cs index d2d572b..0ecff28 100644 --- a/PetWashControl/ViewModels/MainViewModel.cs +++ b/PetWashControl/ViewModels/MainViewModel.cs @@ -25,6 +25,18 @@ public partial class WashStep : ObservableObject private bool _isActive; } +public sealed record PackageTimingProfile( + int FirstSprayWaterTime, + int AfterShampoo1SprayTime, + int AfterShampoo2SprayTime, + int AfterShampoo3SprayTime, + int SprayShampoo1Time, + int SprayShampoo2Time, + int SprayShampoo3Time, + int HotAirTime, + int ColdAirTime, + int UvSterilizationTime); + public partial class MainViewModel : ObservableObject { private readonly ApiService _apiService; @@ -34,6 +46,7 @@ public partial class MainViewModel : ObservableObject private readonly LogService _logger; public event Action? ViewChanged; + public event Action? OpenPackageManagementRequested; [ObservableProperty] private ObservableCollection _packages = new(); @@ -121,6 +134,42 @@ public partial class MainViewModel : ObservableObject [ObservableProperty] private bool _isCustomerServiceDialogOpen; + [ObservableProperty] + private Package? _editingPackage; + + [ObservableProperty] + private decimal _editingPackagePrice = 0.01m; + + [ObservableProperty] + private int _editingPackageFirstSprayWaterTime = 2; + + [ObservableProperty] + private int _editingPackageAfterShampoo1SprayTime = 2; + + [ObservableProperty] + private int _editingPackageAfterShampoo2SprayTime = 2; + + [ObservableProperty] + private int _editingPackageAfterShampoo3SprayTime = 2; + + [ObservableProperty] + private int _editingPackageSprayShampoo1Time = 1; + + [ObservableProperty] + private int _editingPackageSprayShampoo2Time = 1; + + [ObservableProperty] + private int _editingPackageSprayShampoo3Time = 1; + + [ObservableProperty] + private int _editingPackageColdAirTime = 2; + + [ObservableProperty] + private int _editingPackageHotAirTime = 5; + + [ObservableProperty] + private int _editingPackageUvSterilizationTime = 2; + [ObservableProperty] private int _firstSprayWaterTime = 2; @@ -179,6 +228,18 @@ public partial class MainViewModel : ObservableObject private bool _isPaymentPolling; private bool _isCheckingPaymentStatus; + public int EditingPackageTotalDuration => + EditingPackageFirstSprayWaterTime + + EditingPackageAfterShampoo1SprayTime + + EditingPackageAfterShampoo2SprayTime + + EditingPackageAfterShampoo3SprayTime + + EditingPackageSprayShampoo1Time + + EditingPackageSprayShampoo2Time + + EditingPackageSprayShampoo3Time + + EditingPackageHotAirTime + + EditingPackageColdAirTime + + EditingPackageUvSterilizationTime; + public MainViewModel() { _config = new ConfigurationService(); @@ -458,6 +519,11 @@ public partial class MainViewModel : ObservableObject _logger.LogInfo($"切换到设置界面,之前的视图: {_previousView}"); } + public void ShowSettingsFromAdmin() + { + ShowSettings(); + } + [RelayCommand] private void ShowInstruction() { @@ -504,6 +570,7 @@ public partial class MainViewModel : ObservableObject _config.ColdAirTime = ColdAirTime; _config.HotAirTime = HotAirTime; _config.UvSterilizationTime = UvSterilizationTime; + _config.Save(); _logger.LogInfo($"本地参数已更新 - 首次喷水:{FirstSprayWaterTime}min, 沐浴1后:{AfterShampoo1SprayTime}min, " + $"沐浴2后:{AfterShampoo2SprayTime}min, 沐浴3后:{AfterShampoo3SprayTime}min, " + @@ -570,6 +637,75 @@ public partial class MainViewModel : ObservableObject } } + private PackageTimingProfile GetPackageTimingProfile(Package package) + { + if (package.FirstSprayWaterTime > 0 && + package.AfterShampoo1SprayTime > 0 && + package.AfterShampoo2SprayTime > 0 && + package.AfterShampoo3SprayTime > 0 && + package.SprayShampoo1Time > 0 && + package.SprayShampoo2Time > 0 && + package.SprayShampoo3Time > 0 && + package.HotAirTime > 0 && + package.ColdAirTime > 0 && + package.UvSterilizationTime > 0) + { + return new PackageTimingProfile( + package.FirstSprayWaterTime, + package.AfterShampoo1SprayTime, + package.AfterShampoo2SprayTime, + package.AfterShampoo3SprayTime, + package.SprayShampoo1Time, + package.SprayShampoo2Time, + package.SprayShampoo3Time, + package.HotAirTime, + package.ColdAirTime, + package.UvSterilizationTime); + } + + return new PackageTimingProfile( + _config.FirstSprayWaterTime, + _config.AfterShampoo1SprayTime, + _config.AfterShampoo2SprayTime, + _config.AfterShampoo3SprayTime, + _config.SprayShampoo1Time, + _config.SprayShampoo2Time, + _config.SprayShampoo3Time, + _config.HotAirTime, + _config.ColdAirTime, + _config.UvSterilizationTime); + } + + private void ApplyPackageTimingProfile(PackageTimingProfile profile) + { + FirstSprayWaterTime = profile.FirstSprayWaterTime; + AfterShampoo1SprayTime = profile.AfterShampoo1SprayTime; + AfterShampoo2SprayTime = profile.AfterShampoo2SprayTime; + AfterShampoo3SprayTime = profile.AfterShampoo3SprayTime; + SprayShampoo1Time = profile.SprayShampoo1Time; + SprayShampoo2Time = profile.SprayShampoo2Time; + SprayShampoo3Time = profile.SprayShampoo3Time; + HotAirTime = profile.HotAirTime; + ColdAirTime = profile.ColdAirTime; + UvSterilizationTime = profile.UvSterilizationTime; + } + + private async Task ApplyPackageTimingProfileAsync(Package package) + { + var profile = GetPackageTimingProfile(package); + ApplyPackageTimingProfile(profile); + + _logger.LogInfo( + $"套餐参数已切换: PackageId={package.Id}, FirstSpray={FirstSprayWaterTime}, Shampoo1={SprayShampoo1Time}, " + + $"Rinse1={AfterShampoo1SprayTime}, Shampoo2={SprayShampoo2Time}, Rinse2={AfterShampoo2SprayTime}, " + + $"Conditioner={SprayShampoo3Time}, Rinse3={AfterShampoo3SprayTime}, HotAir={HotAirTime}, ColdAir={ColdAirTime}, UV={UvSterilizationTime}"); + + if (IsModbusConnected) + { + await SaveParametersToDeviceAsync(); + } + } + // 参数调整命令 [RelayCommand] private void IncreaseFirstSprayWater() => FirstSprayWaterTime = Math.Min(FirstSprayWaterTime + 1, 60); @@ -695,30 +831,7 @@ public partial class MainViewModel : ObservableObject try { - // 创建订单 - var createOrderResponse = await _apiService.CreateOrderAsync(package.Id); - CurrentOrder = createOrderResponse?.Order; - PaymentCodeUrl = createOrderResponse?.CodeUrl ?? ""; - PaymentOutTradeNo = createOrderResponse?.OutTradeNo ?? ""; - PaymentExpiresAt = createOrderResponse?.ExpiresAt; - IsPaymentExpired = false; - PaymentStatusText = "等待扫码支付"; - RefreshPaymentCountdown(); - PaymentQrCodeImage = string.IsNullOrWhiteSpace(PaymentCodeUrl) - ? null - : BuildPaymentQrCodeImage(PaymentCodeUrl); - - if (CurrentOrder == null || PaymentQrCodeImage == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo)) - { - throw new InvalidOperationException("Payment QR code was not returned by the server."); - } - StatusMessage = $"订单创建成功,请扫码支付 ¥{package.Price}"; - _logger.LogInfo($"订单创建成功,订单ID: {CurrentOrder?.Id}"); - - // 切换到二维码支付界面 - CurrentView = "QRCode"; - ViewChanged?.Invoke("QRCode"); - StartPaymentStatusPolling(); + await CreatePaymentOrderAsync(package, true); } catch (Exception ex) { @@ -748,28 +861,11 @@ public partial class MainViewModel : ObservableObject try { - _logger.LogInfo($"创建订单,套餐ID: {SelectedPackage.Id}"); - var createOrderResponse = await _apiService.CreateOrderAsync(SelectedPackage.Id); - CurrentOrder = createOrderResponse?.Order; - PaymentCodeUrl = createOrderResponse?.CodeUrl ?? ""; - PaymentOutTradeNo = createOrderResponse?.OutTradeNo ?? ""; - PaymentExpiresAt = createOrderResponse?.ExpiresAt; - IsPaymentExpired = false; - PaymentStatusText = "等待扫码支付"; - RefreshPaymentCountdown(); - PaymentQrCodeImage = string.IsNullOrWhiteSpace(PaymentCodeUrl) - ? null - : BuildPaymentQrCodeImage(PaymentCodeUrl); - - if (CurrentOrder == null || PaymentQrCodeImage == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo)) - { - throw new InvalidOperationException("Payment QR code was not returned by the server."); - } - StatusMessage = $"订单创建成功,订单号: {CurrentOrder?.Id},请支付 ¥{SelectedPackage.Price}"; - _logger.LogInfo($"订单创建成功,订单ID: {CurrentOrder?.Id}"); + await CreatePaymentOrderAsync(SelectedPackage, false); } catch (Exception ex) { + StopPaymentStatusPolling(); _logger.LogError("创建订单失败", ex); StatusMessage = $"创建订单失败: {ex.Message}"; MessageBox.Show($"创建订单失败\n\n{ex.Message}", "错误", @@ -808,6 +904,71 @@ public partial class MainViewModel : ObservableObject } } + [RelayCommand] + private async Task RegeneratePaymentQrAsync() + { + if (SelectedPackage == null) + { + return; + } + + try + { + StopPaymentStatusPolling(); + PaymentStatusText = "正在刷新二维码..."; + StatusMessage = "正在刷新二维码..."; + + if (CurrentOrder != null && !CurrentOrder.IsPaid) + { + await _apiService.UpdateOrderStatusAsync(CurrentOrder.Id, OrderStatus.Cancelled); + } + + await CreatePaymentOrderAsync(SelectedPackage, true); + } + catch (Exception ex) + { + _logger.LogError("刷新支付二维码失败", ex); + PaymentStatusText = "二维码刷新失败"; + StatusMessage = $"二维码刷新失败: {ex.Message}"; + MessageBox.Show($"二维码刷新失败\n\n{ex.Message}", "错误", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private async Task CreatePaymentOrderAsync(Package package, bool navigateToQrCode) + { + _logger.LogInfo($"创建订单,套餐ID: {package.Id}"); + await ApplyPackageTimingProfileAsync(package); + + var createOrderResponse = await _apiService.CreateOrderAsync(package.Id); + CurrentOrder = createOrderResponse?.Order; + PaymentCodeUrl = createOrderResponse?.CodeUrl ?? ""; + PaymentOutTradeNo = createOrderResponse?.OutTradeNo ?? ""; + PaymentExpiresAt = createOrderResponse?.ExpiresAt; + IsPaymentExpired = false; + PaymentStatusText = "等待扫码支付"; + RefreshPaymentCountdown(); + PaymentQrCodeImage = string.IsNullOrWhiteSpace(PaymentCodeUrl) + ? null + : BuildPaymentQrCodeImage(PaymentCodeUrl); + + if (CurrentOrder == null || PaymentQrCodeImage == null || string.IsNullOrWhiteSpace(PaymentOutTradeNo)) + { + throw new InvalidOperationException("Payment QR code was not returned by the server."); + } + + StatusMessage = $"订单创建成功,请扫码支付 ¥{package.DisplayPrice}"; + _logger.LogInfo($"订单创建成功,订单ID: {CurrentOrder?.Id}"); + + if (navigateToQrCode) + { + CurrentView = "QRCode"; + ViewChanged?.Invoke("QRCode"); + } + + StartPaymentStatusPolling(); + } + private static BitmapImage BuildPaymentQrCodeImage(string codeUrl) { using var generator = new QRCodeGenerator(); @@ -1379,6 +1540,154 @@ public partial class MainViewModel : ObservableObject }); } + [RelayCommand] + private void OpenPackageManagement() + { + if (Packages.Count == 0) + { + StatusMessage = "套餐列表未加载"; + return; + } + + EditingPackage ??= Packages[0]; + OpenPackageManagementRequested?.Invoke(); + } + + [RelayCommand] + private async Task SavePackageConfigAsync() + { + if (EditingPackage == null) + { + MessageBox.Show("请先选择要编辑的套餐。", "提示", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + if (CurrentOrder != null && !CurrentOrder.IsPaid) + { + MessageBox.Show("当前有未完成支付订单,不能修改套餐配置。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + if (EditingPackagePrice <= 0 || + EditingPackageFirstSprayWaterTime <= 0 || + EditingPackageAfterShampoo1SprayTime <= 0 || + EditingPackageAfterShampoo2SprayTime <= 0 || + EditingPackageAfterShampoo3SprayTime <= 0 || + EditingPackageSprayShampoo1Time <= 0 || + EditingPackageSprayShampoo2Time <= 0 || + EditingPackageSprayShampoo3Time <= 0 || + EditingPackageHotAirTime <= 0 || + EditingPackageColdAirTime <= 0 || + EditingPackageUvSterilizationTime <= 0) + { + MessageBox.Show("套餐金额和每个流程时间都必须大于 0。", "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + try + { + StatusMessage = $"正在保存 {EditingPackage.Name} 配置..."; + + var packageToSave = new Package + { + Id = EditingPackage.Id, + Name = EditingPackage.Name, + Description = EditingPackage.Description, + Price = EditingPackagePrice, + DurationMinutes = EditingPackageTotalDuration, + FirstSprayWaterTime = EditingPackageFirstSprayWaterTime, + AfterShampoo1SprayTime = EditingPackageAfterShampoo1SprayTime, + AfterShampoo2SprayTime = EditingPackageAfterShampoo2SprayTime, + AfterShampoo3SprayTime = EditingPackageAfterShampoo3SprayTime, + SprayShampoo1Time = EditingPackageSprayShampoo1Time, + SprayShampoo2Time = EditingPackageSprayShampoo2Time, + SprayShampoo3Time = EditingPackageSprayShampoo3Time, + HotAirTime = EditingPackageHotAirTime, + ColdAirTime = EditingPackageColdAirTime, + UvSterilizationTime = EditingPackageUvSterilizationTime + }; + + var savedPackage = await _apiService.UpdatePackageAsync(packageToSave) + ?? throw new InvalidOperationException("Server did not return the updated package."); + + ReplacePackageInCollection(savedPackage); + EditingPackage = savedPackage; + + if (SelectedPackage?.Id == savedPackage.Id) + { + SelectedPackage = savedPackage; + await ApplyPackageTimingProfileAsync(savedPackage); + } + + if (CurrentOrder?.PackageId == savedPackage.Id && CurrentOrder.IsPaid) + { + CurrentOrder.Package = savedPackage; + } + + StatusMessage = $"{savedPackage.Name} 配置已保存"; + MessageBox.Show( + $"{savedPackage.Name} 已更新。\n\n金额: {savedPackage.DisplayPrice} 元\n总时长: {savedPackage.DurationMinutes} 分钟", + "保存成功", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + catch (Exception ex) + { + _logger.LogError("保存套餐配置失败", ex); + StatusMessage = $"保存套餐配置失败: {ex.Message}"; + MessageBox.Show($"保存套餐配置失败\n\n{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + partial void OnEditingPackageChanged(Package? value) + { + if (value != null) + { + LoadEditingPackage(value); + } + } + + partial void OnEditingPackageFirstSprayWaterTimeChanged(int value) => OnPropertyChanged(nameof(EditingPackageTotalDuration)); + partial void OnEditingPackageAfterShampoo1SprayTimeChanged(int value) => OnPropertyChanged(nameof(EditingPackageTotalDuration)); + partial void OnEditingPackageAfterShampoo2SprayTimeChanged(int value) => OnPropertyChanged(nameof(EditingPackageTotalDuration)); + partial void OnEditingPackageAfterShampoo3SprayTimeChanged(int value) => OnPropertyChanged(nameof(EditingPackageTotalDuration)); + partial void OnEditingPackageSprayShampoo1TimeChanged(int value) => OnPropertyChanged(nameof(EditingPackageTotalDuration)); + partial void OnEditingPackageSprayShampoo2TimeChanged(int value) => OnPropertyChanged(nameof(EditingPackageTotalDuration)); + partial void OnEditingPackageSprayShampoo3TimeChanged(int value) => OnPropertyChanged(nameof(EditingPackageTotalDuration)); + partial void OnEditingPackageHotAirTimeChanged(int value) => OnPropertyChanged(nameof(EditingPackageTotalDuration)); + partial void OnEditingPackageColdAirTimeChanged(int value) => OnPropertyChanged(nameof(EditingPackageTotalDuration)); + partial void OnEditingPackageUvSterilizationTimeChanged(int value) => OnPropertyChanged(nameof(EditingPackageTotalDuration)); + + private void ReplacePackageInCollection(Package updatedPackage) + { + for (var i = 0; i < Packages.Count; i++) + { + if (Packages[i].Id == updatedPackage.Id) + { + Packages[i] = updatedPackage; + return; + } + } + + Packages.Add(updatedPackage); + } + + private void LoadEditingPackage(Package package) + { + EditingPackagePrice = package.Price; + EditingPackageFirstSprayWaterTime = package.FirstSprayWaterTime; + EditingPackageAfterShampoo1SprayTime = package.AfterShampoo1SprayTime; + EditingPackageAfterShampoo2SprayTime = package.AfterShampoo2SprayTime; + EditingPackageAfterShampoo3SprayTime = package.AfterShampoo3SprayTime; + EditingPackageSprayShampoo1Time = package.SprayShampoo1Time; + EditingPackageSprayShampoo2Time = package.SprayShampoo2Time; + EditingPackageSprayShampoo3Time = package.SprayShampoo3Time; + EditingPackageHotAirTime = package.HotAirTime; + EditingPackageColdAirTime = package.ColdAirTime; + EditingPackageUvSterilizationTime = package.UvSterilizationTime; + OnPropertyChanged(nameof(EditingPackageTotalDuration)); + } + private void UpdateTemperatures(string stepName, int currentTime, int totalTime) { // 根据不同步骤模拟温度变化 diff --git a/PetWashControl/Views/AdminLoginWindow.xaml b/PetWashControl/Views/AdminLoginWindow.xaml new file mode 100644 index 0000000..0cf0113 --- /dev/null +++ b/PetWashControl/Views/AdminLoginWindow.xaml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +