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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +