作者 李壮

inbound material page

  1 +using System.Net.Http.Headers;
  2 +
  3 +namespace IndustrialControl;
  4 +
  5 +public static class ApiClient
  6 +{
  7 + public static readonly HttpClient Instance = new HttpClient();
  8 +
  9 + public static void ConfigureBase(string ip, int port)
  10 + => Instance.BaseAddress = new Uri($"http://{ip}:{port}");
  11 +
  12 + public static void SetBearer(string? token)
  13 + {
  14 + if (string.IsNullOrWhiteSpace(token))
  15 + Instance.DefaultRequestHeaders.Authorization = null;
  16 + else
  17 + Instance.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
  18 + }
  19 +}
@@ -27,7 +27,8 @@ @@ -27,7 +27,8 @@
27 </Setter.Value> 27 </Setter.Value>
28 </Setter> 28 </Setter>
29 </Style> 29 </Style>
30 - 30 + <converters:NullToBoolConverter x:Key="NullToBoolConverter" xmlns:converters="clr-namespace:IndustrialControl.Converters" />
  31 + <converters:IntConverter x:Key="IntConverter" xmlns:converters="clr-namespace:IndustrialControl.Converters" />
31 </ResourceDictionary> 32 </ResourceDictionary>
32 </Application.Resources> 33 </Application.Resources>
33 </Application> 34 </Application>
  1 +using Microsoft.Extensions.DependencyInjection;
  2 +
1 namespace IndustrialControl; 3 namespace IndustrialControl;
2 4
3 public partial class App : Application 5 public partial class App : Application
4 { 6 {
5 - public App(AppShell shell) 7 + private readonly IConfigLoader _configLoader;
  8 + public static IServiceProvider? Services { get; set; }
  9 + public App(IServiceProvider sp, IConfigLoader configLoader)
6 { 10 {
7 InitializeComponent(); 11 InitializeComponent();
8 - MainPage = shell; 12 +
  13 + _configLoader = configLoader;
  14 + // 1) 立刻给 MainPage 一个默认值,避免 null
  15 + MainPage = new AppShell(authed: false); // 显示:登录|日志|管理员
  16 + // 启动根据是否已登录选择壳
  17 + _ = InitAsync();
  18 +
  19 + }
  20 +
  21 + protected override async void OnStart()
  22 + {
  23 + base.OnStart();
  24 +
  25 + // 1) 启动时确保配置已覆盖(这里才能 await)
  26 + await _configLoader.EnsureLatestAsync();
  27 +
  28 + // 2) 判断是否已登录
  29 + var token = await TokenStorage.GetAsync();
  30 + var isLoggedIn = !string.IsNullOrWhiteSpace(token);
9 } 31 }
  32 +
  33 + private async Task InitAsync()
  34 + {
  35 + var token = await TokenStorage.LoadAsync();
  36 + if (!string.IsNullOrWhiteSpace(token))
  37 + {
  38 + ApiClient.SetBearer(token);
  39 + MainThread.BeginInvokeOnMainThread(() =>
  40 + {
  41 + Current.MainPage = new AppShell(authed: true); // 显示:主页|日志|管理员
  42 + });
  43 + }
  44 + }
  45 +
  46 + public static void SwitchToLoggedInShell() => Current.MainPage = new AppShell(true);
  47 + public static void SwitchToLoggedOutShell() => Current.MainPage = new AppShell(false);
10 } 48 }
1 using Microsoft.Extensions.DependencyInjection; 1 using Microsoft.Extensions.DependencyInjection;
2 -using Microsoft.Maui.Controls;  
3 2
4 -namespace IndustrialControl 3 +namespace IndustrialControl;
  4 +
  5 +public partial class AppShell : Shell
5 { 6 {
6 - public partial class AppShell : Shell  
7 - {  
8 - public AppShell(IServiceProvider sp) 7 + private readonly IServiceProvider _sp;
  8 +
  9 + public AppShell(bool authed)
9 { 10 {
10 InitializeComponent(); 11 InitializeComponent();
  12 + _sp = App.Services ?? throw new InvalidOperationException("Services not ready");
  13 + Routing.RegisterRoute(nameof(Pages.InboundMaterialSearchPage), typeof(Pages.InboundMaterialSearchPage));
  14 + Routing.RegisterRoute(nameof(Pages.InboundMaterialPage), typeof(Pages.InboundMaterialPage));
  15 + Routing.RegisterRoute(nameof(Pages.InboundProductionPage), typeof(Pages.InboundProductionPage));
  16 + Routing.RegisterRoute(nameof(Pages.OutboundMaterialPage), typeof(Pages.OutboundMaterialPage));
  17 + Routing.RegisterRoute(nameof(Pages.OutboundFinishedPage), typeof(Pages.OutboundFinishedPage));
  18 + BuildTabs(authed);
  19 + }
11 20
12 - // Tab: 登录  
13 - var login = new ShellContent 21 + private void BuildTabs(bool authed)
14 { 22 {
15 - Title = "登录",  
16 - Route = "Login",  
17 - ContentTemplate = new DataTemplate(() => sp.GetRequiredService<Pages.LoginPage>())  
18 - }; 23 + Items.Clear();
  24 +
  25 + var bar = new TabBar();
19 26
20 - // Tab: 主页  
21 - var home = new ShellContent 27 + // 公共页:日志
  28 + bar.Items.Add(new Tab
22 { 29 {
23 - Title = "主页",  
24 - Route = "Home",  
25 - ContentTemplate = new DataTemplate(() => sp.GetRequiredService<Pages.HomePage>())  
26 - }; 30 + Title = "日志",
  31 + Items =
  32 + {
  33 + new ShellContent
  34 + {
  35 + Route = "Logs",
  36 + ContentTemplate = new DataTemplate(() => _sp.GetRequiredService<Pages.LogsPage>())
  37 + }
  38 + }
  39 + });
27 40
28 - // Tab: 管理员  
29 - var admin = new ShellContent 41 + // 公共页:管理员
  42 + bar.Items.Add(new Tab
30 { 43 {
31 Title = "管理员", 44 Title = "管理员",
  45 + Items =
  46 + {
  47 + new ShellContent
  48 + {
32 Route = "Admin", 49 Route = "Admin",
33 - ContentTemplate = new DataTemplate(() => sp.GetRequiredService<Pages.AdminPage>())  
34 - }; 50 + ContentTemplate = new DataTemplate(() => _sp.GetRequiredService<Pages.AdminPage>())
  51 + }
  52 + }
  53 + });
35 54
36 - // Tab: 日志  
37 - var logs = new ShellContent 55 + if (authed)
38 { 56 {
39 - Title = "日志",  
40 - Route = "Logs",  
41 - ContentTemplate = new DataTemplate(() => sp.GetRequiredService<Pages.LogsPage>())  
42 - };  
43 -  
44 - var tabBar = new TabBar();  
45 - tabBar.Items.Add(login);  
46 - tabBar.Items.Add(home);  
47 - tabBar.Items.Add(admin);  
48 - tabBar.Items.Add(logs); 57 + // 已登录:插入主页到最前
  58 + bar.Items.Insert(0, new Tab
  59 + {
  60 + Title = "主页",
  61 + Items =
  62 + {
  63 + new ShellContent
  64 + {
  65 + Route = "Home",
  66 + ContentTemplate = new DataTemplate(() => _sp.GetRequiredService<Pages.HomePage>())
  67 + }
  68 + }
  69 + });
49 70
50 - Items.Add(tabBar); 71 + Items.Add(bar);
  72 + _ = GoToAsync("//Home");
  73 + }
  74 + else
  75 + {
  76 + // 未登录:插入登录到最前
  77 + bar.Items.Insert(0, new Tab
  78 + {
  79 + Title = "登录",
  80 + Items =
  81 + {
  82 + new ShellContent
  83 + {
  84 + Route = "Login",
  85 + ContentTemplate = new DataTemplate(() => _sp.GetRequiredService<Pages.LoginPage>())
  86 + }
  87 + }
  88 + });
51 89
52 - Routing.RegisterRoute(nameof(Pages.InboundMaterialPage), typeof(Pages.InboundMaterialPage));  
53 - Routing.RegisterRoute(nameof(Pages.InboundProductionPage), typeof(Pages.InboundProductionPage));  
54 - Routing.RegisterRoute(nameof(Pages.OutboundMaterialPage), typeof(Pages.OutboundMaterialPage));  
55 - Routing.RegisterRoute(nameof(Pages.OutboundFinishedPage), typeof(Pages.OutboundFinishedPage)); 90 + Items.Add(bar);
  91 + _ = GoToAsync("//Login");
56 } 92 }
57 } 93 }
  94 +
58 } 95 }
  1 +using System.Text.Json;
  2 +using System.Text.Json.Nodes;
  3 +
  4 +public interface IConfigLoader
  5 +{
  6 + Task EnsureLatestAsync();
  7 + JsonNode Load();
  8 + void Save(JsonNode node);
  9 +}
  10 +
  11 +public class ConfigLoader : IConfigLoader
  12 +{
  13 + public Task EnsureLatestAsync() => ConfigLoaderStatic.EnsureConfigIsLatestAsync();
  14 + public JsonNode Load() => ConfigLoaderStatic.Load();
  15 + public void Save(JsonNode node) => ConfigLoaderStatic.Save(node);
  16 +}
  17 +
  18 +public static class ConfigLoaderStatic
  19 +{
  20 + private const string FileName = "appconfig.json";
  21 + private static readonly JsonSerializerOptions JsonOpts = new()
  22 + {
  23 + WriteIndented = true,
  24 + PropertyNameCaseInsensitive = true
  25 + };
  26 +
  27 + /// <summary>
  28 + /// 用包内更高版本覆盖本地(不做任何字段保留/合并)。
  29 + /// 首次运行:直接复制包内文件到 AppData。
  30 + /// </summary>
  31 + public static async Task EnsureConfigIsLatestAsync()
  32 + {
  33 + var appDataPath = Path.Combine(FileSystem.AppDataDirectory, FileName);
  34 +
  35 + // 读包内
  36 + JsonNode pkgNode;
  37 + using (var s = await FileSystem.OpenAppPackageFileAsync(FileName))
  38 + using (var reader = new StreamReader(s))
  39 + {
  40 + pkgNode = JsonNode.Parse(await reader.ReadToEndAsync())!;
  41 + }
  42 +
  43 + // 本地不存在 → 直接落地
  44 + if (!File.Exists(appDataPath))
  45 + {
  46 + await File.WriteAllTextAsync(appDataPath, pkgNode.ToJsonString(JsonOpts));
  47 + return;
  48 + }
  49 +
  50 + // 读本地
  51 + JsonNode localNode;
  52 + using (var fs = new FileStream(appDataPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
  53 + {
  54 + localNode = JsonNode.Parse(fs)!;
  55 + }
  56 +
  57 + int pkgVer = pkgNode?["schemaVersion"]?.GetValue<int?>() ?? 0;
  58 + int localVer = localNode?["schemaVersion"]?.GetValue<int?>() ?? 0;
  59 +
  60 + // 包内版本更高 → 直接覆盖(整文件替换)
  61 + if (pkgVer > localVer)
  62 + {
  63 + await File.WriteAllTextAsync(appDataPath, pkgNode.ToJsonString(JsonOpts));
  64 + }
  65 + // 否则保持本地不动
  66 + }
  67 +
  68 + /// <summary>读取生效配置(AppData)</summary>
  69 + public static JsonNode Load() =>
  70 + JsonNode.Parse(File.ReadAllText(Path.Combine(FileSystem.AppDataDirectory, FileName)))!;
  71 +
  72 + /// <summary>保存(如果你在设置页手动修改本地配置)</summary>
  73 + public static void Save(JsonNode node) =>
  74 + File.WriteAllText(Path.Combine(FileSystem.AppDataDirectory, FileName), node.ToJsonString(JsonOpts));
  75 +}
  1 +using System.Globalization;
  2 +
  3 +namespace IndustrialControl.Converters;
  4 +
  5 +public class BoolNegationConverter : IValueConverter
  6 +{
  7 + public object Convert(object value, Type t, object p, CultureInfo c)
  8 + => value is bool b ? !b : value;
  9 + public object ConvertBack(object value, Type t, object p, CultureInfo c)
  10 + => value is bool b ? !b : value;
  11 +}
  12 +
  13 +public class BoolToShowHideTextConverter : IValueConverter
  14 +{
  15 + public object Convert(object value, Type t, object p, CultureInfo c)
  16 + => value is bool b && b ? "隐藏" : "显示";
  17 + public object ConvertBack(object value, Type t, object p, CultureInfo c) => throw new NotSupportedException();
  18 +}
  1 +using System.Globalization;
  2 +
  3 +namespace IndustrialControl.Converters;
  4 +
  5 +public class EyeGlyphConverter : IValueConverter
  6 +{
  7 + public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
  8 + {
  9 + if (value is bool b && b)
  10 + return "\uE70E"; // 眼睛(显示密码)
  11 + return "\uEB11"; // 眼睛关闭(隐藏密码)
  12 + }
  13 +
  14 + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
  15 + => throw new NotSupportedException();
  16 +}
  1 +using System.Globalization;
  2 +
  3 +namespace IndustrialControl.Converters;
  4 +public class IntConverter : IValueConverter
  5 +{
  6 + public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
  7 + => value?.ToString() ?? "0";
  8 +
  9 + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
  10 + {
  11 + var s = value?.ToString();
  12 + return int.TryParse(s, out var n) && n >= 0 ? n : 0; // 非法输入归零;需要可负数就去掉判断
  13 + }
  14 +}
  1 +using System.Globalization;
  2 +
  3 +namespace IndustrialControl.Converters;
  4 +public class NullToBoolConverter : IValueConverter
  5 +{
  6 + public bool Invert { get; set; } = false;
  7 +
  8 + public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
  9 + => Invert ? value is null : value is not null;
  10 +
  11 + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
  12 + => throw new NotSupportedException();
  13 +}
@@ -54,11 +54,17 @@ @@ -54,11 +54,17 @@
54 54
55 <!-- Raw Assets (also remove the "Resources\Raw" prefix) --> 55 <!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
56 <MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" /> 56 <MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
  57 + <AndroidResource Remove="Platforms\Android\Resources\xml\network_security_config.xml" />
  58 +
  59 + <!-- 确保 appconfig.json 被打包到应用包,逻辑名=文件名 -->
  60 + <MauiAsset Include="Resources\Raw\appconfig.json" LogicalName="appconfig.json">
  61 + <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  62 + </MauiAsset>
57 </ItemGroup> 63 </ItemGroup>
58 64
59 <ItemGroup> 65 <ItemGroup>
60 <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" /> 66 <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
61 - <PackageReference Include="Microsoft.Maui.Controls" Version="8.0.0"/> 67 + <PackageReference Include="Microsoft.Maui.Controls" Version="8.0.0" />
62 <PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="8.0.0" /> 68 <PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="8.0.0" />
63 <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.1" /> 69 <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.1" />
64 </ItemGroup> 70 </ItemGroup>
@@ -76,6 +82,9 @@ @@ -76,6 +82,9 @@
76 <MauiXaml Update="Pages\InboundMaterialPage.xaml"> 82 <MauiXaml Update="Pages\InboundMaterialPage.xaml">
77 <Generator>MSBuild:Compile</Generator> 83 <Generator>MSBuild:Compile</Generator>
78 </MauiXaml> 84 </MauiXaml>
  85 + <MauiXaml Update="Pages\InboundMaterialSearchPage.xaml">
  86 + <Generator>MSBuild:Compile</Generator>
  87 + </MauiXaml>
79 <MauiXaml Update="Pages\InboundProductionPage.xaml"> 88 <MauiXaml Update="Pages\InboundProductionPage.xaml">
80 <Generator>MSBuild:Compile</Generator> 89 <Generator>MSBuild:Compile</Generator>
81 </MauiXaml> 90 </MauiXaml>
@@ -17,6 +17,7 @@ Global @@ -17,6 +17,7 @@ Global
17 GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 GlobalSection(ProjectConfigurationPlatforms) = postSolution
18 {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
19 {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|Any CPU.Build.0 = Debug|Any CPU
  20 + {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
20 {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|x64.ActiveCfg = Debug|Any CPU 21 {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|x64.ActiveCfg = Debug|Any CPU
21 {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|x64.Build.0 = Debug|Any CPU 22 {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|x64.Build.0 = Debug|Any CPU
22 {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|x86.ActiveCfg = Debug|Any CPU 23 {A54387CA-553D-4CE0-B86C-08A45497D703}.Debug|x86.ActiveCfg = Debug|Any CPU
@@ -16,9 +16,12 @@ namespace IndustrialControl @@ -16,9 +16,12 @@ namespace IndustrialControl
16 fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); 16 fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
17 fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); 17 fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
18 }); 18 });
  19 +#if DEBUG
  20 + builder.Logging.AddDebug(); // ✅ 放在 Build 之前
  21 +#endif
19 builder.Services.AddSingleton<AppShell>(); 22 builder.Services.AddSingleton<AppShell>();
20 // 注册 ConfigLoader 23 // 注册 ConfigLoader
21 - builder.Services.AddSingleton<Services.ConfigLoader>(); 24 + builder.Services.AddSingleton<IConfigLoader, ConfigLoader>();
22 builder.Services.AddSingleton<Services.LogService>(); 25 builder.Services.AddSingleton<Services.LogService>();
23 builder.Services.AddSingleton<IWarehouseDataService, MockWarehouseDataService>(); 26 builder.Services.AddSingleton<IWarehouseDataService, MockWarehouseDataService>();
24 27
@@ -31,6 +34,7 @@ namespace IndustrialControl @@ -31,6 +34,7 @@ namespace IndustrialControl
31 builder.Services.AddTransient<ViewModels.HomeViewModel>(); 34 builder.Services.AddTransient<ViewModels.HomeViewModel>();
32 builder.Services.AddTransient<ViewModels.AdminViewModel>(); 35 builder.Services.AddTransient<ViewModels.AdminViewModel>();
33 builder.Services.AddTransient<ViewModels.LogsViewModel>(); 36 builder.Services.AddTransient<ViewModels.LogsViewModel>();
  37 + builder.Services.AddTransient<ViewModels.InboundMaterialSearchViewModel>();
34 builder.Services.AddTransient<ViewModels.InboundMaterialViewModel>(); 38 builder.Services.AddTransient<ViewModels.InboundMaterialViewModel>();
35 builder.Services.AddTransient<ViewModels.InboundProductionViewModel>(); 39 builder.Services.AddTransient<ViewModels.InboundProductionViewModel>();
36 builder.Services.AddTransient<ViewModels.OutboundMaterialViewModel>(); 40 builder.Services.AddTransient<ViewModels.OutboundMaterialViewModel>();
@@ -43,16 +47,14 @@ namespace IndustrialControl @@ -43,16 +47,14 @@ namespace IndustrialControl
43 builder.Services.AddTransient<Pages.LogsPage>(); 47 builder.Services.AddTransient<Pages.LogsPage>();
44 48
45 // 注册需要路由的页面 49 // 注册需要路由的页面
  50 + builder.Services.AddTransient<Pages.InboundMaterialSearchPage>();
46 builder.Services.AddTransient<Pages.InboundMaterialPage>(); 51 builder.Services.AddTransient<Pages.InboundMaterialPage>();
47 builder.Services.AddTransient<Pages.InboundProductionPage>(); 52 builder.Services.AddTransient<Pages.InboundProductionPage>();
48 builder.Services.AddTransient<Pages.OutboundMaterialPage>(); 53 builder.Services.AddTransient<Pages.OutboundMaterialPage>();
49 builder.Services.AddTransient<Pages.OutboundFinishedPage>(); 54 builder.Services.AddTransient<Pages.OutboundFinishedPage>();
50 -  
51 -#if DEBUG  
52 - builder.Logging.AddDebug();  
53 -#endif  
54 -  
55 - return builder.Build(); 55 + var app = builder.Build();
  56 + App.Services = app.Services;
  57 + return app;
56 } 58 }
57 } 59 }
58 } 60 }
@@ -2,12 +2,12 @@ namespace IndustrialControl.Models; @@ -2,12 +2,12 @@ namespace IndustrialControl.Models;
2 2
3 public class ServerSettings 3 public class ServerSettings
4 { 4 {
5 - public string IpAddress { get; set; } = "192.168.1.100"; 5 + public string IpAddress { get; set; }
6 public int Port { get; set; } = 8080; 6 public int Port { get; set; } = 8080;
7 } 7 }
8 public class ApiEndpoints 8 public class ApiEndpoints
9 { 9 {
10 - public string Login { get; set; } = "/sso/login"; 10 + public string Login { get; set; }
11 } 11 }
12 public class LoggingSettings 12 public class LoggingSettings
13 { 13 {
@@ -2,6 +2,9 @@ @@ -2,6 +2,9 @@
2 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 2 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
3 x:Class="IndustrialControl.Pages.HomePage" 3 x:Class="IndustrialControl.Pages.HomePage"
4 Title="仓储作业"> 4 Title="仓储作业">
  5 + <ContentPage.ToolbarItems>
  6 + <ToolbarItem Text="退出" Clicked="OnLogoutClicked" />
  7 + </ContentPage.ToolbarItems>
5 8
6 <VerticalStackLayout Padding="24" Spacing="16"> 9 <VerticalStackLayout Padding="24" Spacing="16">
7 <Frame Style="{StaticResource BlueCardStyle}"> 10 <Frame Style="{StaticResource BlueCardStyle}">
1 -namespace IndustrialControl.Pages 1 +namespace IndustrialControl.Pages
2 { 2 {
3 public partial class HomePage : ContentPage 3 public partial class HomePage : ContentPage
4 { 4 {
@@ -9,7 +9,7 @@ namespace IndustrialControl.Pages @@ -9,7 +9,7 @@ namespace IndustrialControl.Pages
9 9
10 private async void OnInMat(object sender, EventArgs e) 10 private async void OnInMat(object sender, EventArgs e)
11 { 11 {
12 - await Shell.Current.GoToAsync(nameof(InboundMaterialPage)); 12 + await Shell.Current.GoToAsync(nameof(InboundMaterialSearchPage));
13 } 13 }
14 14
15 private async void OnInProd(object sender, EventArgs e) 15 private async void OnInProd(object sender, EventArgs e)
@@ -26,5 +26,21 @@ namespace IndustrialControl.Pages @@ -26,5 +26,21 @@ namespace IndustrialControl.Pages
26 { 26 {
27 await Shell.Current.GoToAsync(nameof(OutboundFinishedPage)); 27 await Shell.Current.GoToAsync(nameof(OutboundFinishedPage));
28 } 28 }
  29 +
  30 + // 新增:退出登录
  31 + private async void OnLogoutClicked(object? sender, EventArgs e)
  32 + {
  33 + await TokenStorage.ClearAsync(); // 清除 token
  34 + ApiClient.SetBearer(null); // 清空请求头
  35 +
  36 + // 切换到未登录的 Shell:显示 登录|日志|管理员
  37 + MainThread.BeginInvokeOnMainThread(() =>
  38 + {
  39 + App.SwitchToLoggedOutShell();
  40 + });
  41 + }
  42 +
  43 +
  44 +
29 } 45 }
30 } 46 }
@@ -23,29 +23,53 @@ @@ -23,29 +23,53 @@
23 BackgroundColor="White" 23 BackgroundColor="White"
24 HeightRequest="40" 24 HeightRequest="40"
25 Text="{Binding ScanCode}" /> 25 Text="{Binding ScanCode}" />
26 - <ImageButton Grid.Column="1"  
27 - Source="scan.png"  
28 - BackgroundColor="#E6F2FF"  
29 - CornerRadius="4"  
30 - Padding="10"  
31 - Clicked="OnScanClicked"/> 26 +
32 </Grid> 27 </Grid>
33 28
34 <!-- 基础信息 --> 29 <!-- 基础信息 -->
35 - <Frame Grid.Row="2" Margin="16,0" Padding="8" BorderColor="#CCCCCC" BackgroundColor="White">  
36 - <Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto,*" ColumnSpacing="8" RowSpacing="6">  
37 -  
38 - <!-- 第一行 -->  
39 - <Label Grid.Row="0" Grid.Column="0" Text="入库单号:" FontAttributes="Bold" />  
40 - <Label Grid.Row="0" Grid.Column="1" Text="{Binding OrderNo}" LineBreakMode="TailTruncation" />  
41 - <Label Grid.Row="0" Grid.Column="2" Text="关联到货单号:" FontAttributes="Bold" />  
42 - <Label Grid.Row="0" Grid.Column="3" Text="{Binding LinkedDeliveryNo}" LineBreakMode="TailTruncation" />  
43 -  
44 - <!-- 第二行 -->  
45 - <Label Grid.Row="1" Grid.Column="0" Text="关联采购单:" FontAttributes="Bold" />  
46 - <Label Grid.Row="1" Grid.Column="1" Text="{Binding LinkedPurchaseNo}" LineBreakMode="TailTruncation" />  
47 - <Label Grid.Row="1" Grid.Column="2" Text="供应商:" FontAttributes="Bold" />  
48 - <Label Grid.Row="1" Grid.Column="3" Text="{Binding Supplier}" LineBreakMode="TailTruncation" /> 30 + <Frame Grid.Row="2" Margin="8,0" Padding="8" BorderColor="#CCCCCC" BackgroundColor="White">
  31 + <Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,*" ColumnSpacing="16" RowSpacing="6">
  32 +
  33 + <!-- 第一行左:入库单号 -->
  34 + <Label Grid.Row="0" Grid.Column="0" FontSize="13" LineBreakMode="TailTruncation" MaxLines="1">
  35 + <Label.FormattedText>
  36 + <FormattedString>
  37 + <Span Text="入库单号:" FontAttributes="Bold"/>
  38 + <Span Text="{Binding OrderNo}"/>
  39 + </FormattedString>
  40 + </Label.FormattedText>
  41 + </Label>
  42 +
  43 + <!-- 第一行右:关联到货单号 -->
  44 + <Label Grid.Row="0" Grid.Column="1" FontSize="13" LineBreakMode="TailTruncation" MaxLines="1">
  45 + <Label.FormattedText>
  46 + <FormattedString>
  47 + <Span Text="关联到货单号:" FontAttributes="Bold"/>
  48 + <Span Text="{Binding LinkedDeliveryNo}"/>
  49 + </FormattedString>
  50 + </Label.FormattedText>
  51 + </Label>
  52 +
  53 + <!-- 第二行左:关联采购单 -->
  54 + <Label Grid.Row="1" Grid.Column="0" FontSize="13" LineBreakMode="TailTruncation" MaxLines="1">
  55 + <Label.FormattedText>
  56 + <FormattedString>
  57 + <Span Text="关联采购单:" FontAttributes="Bold"/>
  58 + <Span Text="{Binding LinkedPurchaseNo}"/>
  59 + </FormattedString>
  60 + </Label.FormattedText>
  61 + </Label>
  62 +
  63 + <!-- 第二行右:供应商 -->
  64 + <Label Grid.Row="1" Grid.Column="1" FontSize="13" LineBreakMode="TailTruncation" MaxLines="1">
  65 + <Label.FormattedText>
  66 + <FormattedString>
  67 + <Span Text="供应商:" FontAttributes="Bold"/>
  68 + <Span Text="{Binding Supplier}"/>
  69 + </FormattedString>
  70 + </Label.FormattedText>
  71 + </Label>
  72 +
49 </Grid> 73 </Grid>
50 </Frame> 74 </Frame>
51 75
@@ -119,7 +143,8 @@ @@ -119,7 +143,8 @@
119 <CollectionView Grid.Row="2" 143 <CollectionView Grid.Row="2"
120 ItemsSource="{Binding ScannedList}" 144 ItemsSource="{Binding ScannedList}"
121 IsVisible="{Binding IsScannedVisible}" 145 IsVisible="{Binding IsScannedVisible}"
122 - SelectionMode="Single"> 146 + SelectionMode="Single"
  147 + SelectedItem="{Binding SelectedScanItem, Mode=TwoWay}" >
123 <CollectionView.ItemTemplate> 148 <CollectionView.ItemTemplate>
124 <DataTemplate> 149 <DataTemplate>
125 <Grid ColumnDefinitions="40,*,*,*,*,*" Padding="8" BackgroundColor="White"> 150 <Grid ColumnDefinitions="40,*,*,*,*,*" Padding="8" BackgroundColor="White">
@@ -143,15 +168,18 @@ @@ -143,15 +168,18 @@
143 <Label Grid.Column="2" Text="{Binding Name}" /> 168 <Label Grid.Column="2" Text="{Binding Name}" />
144 <Label Grid.Column="3" Text="{Binding Spec}" /> 169 <Label Grid.Column="3" Text="{Binding Spec}" />
145 170
146 - <!-- 改成 Picker --> 171 + <!-- 预入库库位:改为下拉,可选 VM.AvailableBins,双向绑到 item.Bin -->
147 <Picker Grid.Column="4" 172 <Picker Grid.Column="4"
148 - ItemsSource="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.AvailableBins}"  
149 - SelectedItem="{Binding Bin, Mode=TwoWay}"  
150 - FontSize="14"  
151 - Title="请选择"  
152 - HorizontalOptions="FillAndExpand" />  
153 -  
154 - <Label Grid.Column="5" Text="{Binding Qty}" /> 173 + Title="选择库位"
  174 + ItemsSource="{Binding Source={x:Reference Page}, Path=BindingContext.AvailableBins}"
  175 + SelectedItem="{Binding Bin, Mode=TwoWay}" />
  176 +
  177 + <!-- 已扫描数量:可手动改,用数字键盘;建议加转换器容错 -->
  178 + <Entry Grid.Column="5"
  179 + Keyboard="Numeric"
  180 + HorizontalTextAlignment="Center"
  181 + WidthRequest="64"
  182 + Text="{Binding Qty, Mode=TwoWay, Converter={StaticResource IntConverter}}" />
155 </Grid> 183 </Grid>
156 </DataTemplate> 184 </DataTemplate>
157 </CollectionView.ItemTemplate> 185 </CollectionView.ItemTemplate>
@@ -3,10 +3,14 @@ using IndustrialControl.ViewModels; @@ -3,10 +3,14 @@ using IndustrialControl.ViewModels;
3 3
4 namespace IndustrialControl.Pages; 4 namespace IndustrialControl.Pages;
5 5
  6 +// NEW: 支持从查询页传参
  7 +[QueryProperty(nameof(OrderNo), "orderNo")]
6 public partial class InboundMaterialPage : ContentPage 8 public partial class InboundMaterialPage : ContentPage
7 { 9 {
8 private readonly ScanService _scanSvc; 10 private readonly ScanService _scanSvc;
9 private readonly InboundMaterialViewModel _vm; 11 private readonly InboundMaterialViewModel _vm;
  12 + // NEW: 由 Shell 设置
  13 + public string? OrderNo { get; set; }
10 14
11 public InboundMaterialPage(InboundMaterialViewModel vm, ScanService scanSvc) 15 public InboundMaterialPage(InboundMaterialViewModel vm, ScanService scanSvc)
12 { 16 {
@@ -16,9 +20,16 @@ public partial class InboundMaterialPage : ContentPage @@ -16,9 +20,16 @@ public partial class InboundMaterialPage : ContentPage
16 _vm = vm; 20 _vm = vm;
17 } 21 }
18 22
19 - protected override void OnAppearing() 23 + protected override async void OnAppearing()
20 { 24 {
21 base.OnAppearing(); 25 base.OnAppearing();
  26 +
  27 + // 如果带有单号参数,则加载其明细,进入图2/图3的流程
  28 + if (!string.IsNullOrWhiteSpace(OrderNo))
  29 + {
  30 + await _vm.LoadOrderAsync(OrderNo);
  31 + }
  32 +
22 _scanSvc.Attach(ScanEntry); 33 _scanSvc.Attach(ScanEntry);
23 ScanEntry.Focus(); 34 ScanEntry.Focus();
24 } 35 }
@@ -41,21 +52,6 @@ public partial class InboundMaterialPage : ContentPage @@ -41,21 +52,6 @@ public partial class InboundMaterialPage : ContentPage
41 await DisplayAlert("提示", "此按钮预留摄像头扫码;硬件扫描直接扣扳机。", "确定"); 52 await DisplayAlert("提示", "此按钮预留摄像头扫码;硬件扫描直接扣扳机。", "确定");
42 } 53 }
43 54
44 - /// <summary>  
45 - /// 扫描通过按钮点击  
46 - /// </summary>  
47 - void OnPassScanClicked(object sender, EventArgs e)  
48 - {  
49 - _vm.PassSelectedScan();  
50 - }  
51 -  
52 - /// <summary>  
53 - /// 取消扫描按钮点击  
54 - /// </summary>  
55 - void OnCancelScanClicked(object sender, EventArgs e)  
56 - {  
57 - _vm.CancelSelectedScan();  
58 - }  
59 55
60 /// <summary> 56 /// <summary>
61 /// 确认入库按钮点击 57 /// 确认入库按钮点击
  1 +<?xml version="1.0" encoding="utf-8" ?>
  2 +<ContentPage x:Name="Page"
  3 + xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
  4 + xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
  5 + x:Class="IndustrialControl.Pages.InboundMaterialSearchPage"
  6 + Title="仓储管理系统">
  7 +
  8 + <Grid RowDefinitions="Auto,*,Auto" Padding="16" BackgroundColor="#F6F7FB">
  9 +
  10 + <!-- 顶部:输入条件(第0行) -->
  11 + <VerticalStackLayout Grid.Row="0" Spacing="10">
  12 + <Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
  13 + <Entry x:Name="OrderEntry"
  14 + Grid.Row="0" Grid.Column="0"
  15 + Placeholder="请输入入库单号/包裹条码"
  16 + Text="{Binding SearchOrderNo}" />
  17 +
  18 + <DatePicker Grid.Row="1" Grid.Column="0"
  19 + Date="{Binding CreatedDate}"
  20 + MinimumDate="2000-01-01" />
  21 + <Button Grid.Row="1" Grid.Column="1"
  22 + Text="查询"
  23 + Command="{Binding SearchCommand}" />
  24 + </Grid>
  25 + </VerticalStackLayout>
  26 +
  27 + <!-- 中部:结果列表(第1行) -->
  28 + <CollectionView Grid.Row="1"
  29 + ItemsSource="{Binding Orders}"
  30 + SelectionMode="Single"
  31 + SelectionChanged="OnOrderSelected">
  32 + <CollectionView.ItemTemplate>
  33 + <DataTemplate>
  34 + <Frame Margin="0,8,0,0" Padding="12" HasShadow="True" CornerRadius="10">
  35 + <!-- ⭐ 点击整卡片触发命令 -->
  36 + <Frame.GestureRecognizers>
  37 + <TapGestureRecognizer
  38 + Command="{Binding BindingContext.OpenItemCommand, Source={x:Reference Page}}"
  39 + CommandParameter="{Binding .}" />
  40 + </Frame.GestureRecognizers>
  41 + <Grid RowDefinitions="Auto,Auto,Auto,Auto"
  42 + ColumnDefinitions="Auto,*" ColumnSpacing="8">
  43 + <Label Grid.Row="0" Grid.Column="0" Text="入库单号:" FontAttributes="Bold"/>
  44 + <Label Grid.Row="0" Grid.Column="1" Text="{Binding OrderNo}"/>
  45 +
  46 + <Label Grid.Row="1" Grid.Column="0" Text="入库类型:" FontAttributes="Bold"/>
  47 + <Label Grid.Row="1" Grid.Column="1" Text="{Binding InboundType}" />
  48 +
  49 + <Label Grid.Row="2" Grid.Column="0" Text="供应商:" FontAttributes="Bold"/>
  50 + <Label Grid.Row="2" Grid.Column="1" Text="{Binding Supplier}" />
  51 +
  52 + <Label Grid.Row="3" Grid.Column="0" Text="创建日期:" FontAttributes="Bold"/>
  53 + <Label Grid.Row="3" Grid.Column="1" Text="{Binding CreatedAt, StringFormat='{0:yyyy-M-d}'}"/>
  54 + </Grid>
  55 + </Frame>
  56 + </DataTemplate>
  57 + </CollectionView.ItemTemplate>
  58 + </CollectionView>
  59 +
  60 + <!-- 底部:操作(第2行) -->
  61 + <Grid Grid.Row="2" ColumnDefinitions="*,Auto" Padding="0,8,0,0">
  62 + <Label Text="{Binding Orders.Count, StringFormat='共 {0} 条'}"
  63 + VerticalTextAlignment="Center" />
  64 + <Button Grid.Column="1"
  65 + Text="进入入库"
  66 + Command="{Binding GoInboundCommand}"
  67 + IsEnabled="{Binding SelectedOrder, Converter={StaticResource NullToBoolConverter}}"/>
  68 + </Grid>
  69 + </Grid>
  70 +</ContentPage>
  1 +using IndustrialControl.ViewModels;
  2 +
  3 +namespace IndustrialControl.Pages;
  4 +
  5 +public partial class InboundMaterialSearchPage : ContentPage
  6 +{
  7 + public InboundMaterialSearchPage(InboundMaterialSearchViewModel vm)
  8 + {
  9 + InitializeComponent();
  10 + var sp = Application.Current?.Handler?.MauiContext?.Services
  11 + ?? throw new InvalidOperationException("Services not ready");
  12 + BindingContext = sp.GetRequiredService<InboundMaterialSearchViewModel>();
  13 + }
  14 +
  15 +
  16 + private async void OnOrderSelected(object sender, SelectionChangedEventArgs e)
  17 + {
  18 + var item = e.CurrentSelection?.FirstOrDefault() as InboundOrderSummary;
  19 + if (item is null) return;
  20 +
  21 + // 导航到入库页并带上单号
  22 + await Shell.Current.GoToAsync($"{nameof(InboundMaterialPage)}?orderNo={Uri.EscapeDataString(item.OrderNo)}");
  23 +
  24 + // 清除选择,避免返回后仍高亮
  25 + ((CollectionView)sender).SelectedItem = null;
  26 + }
  27 +
  28 +}
1 <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" 1 <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
2 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 2 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
3 - xmlns:pages="clr-namespace:IndustrialControl.Pages" 3 + xmlns:conv="clr-namespace:IndustrialControl.Converters"
4 x:Class="IndustrialControl.Pages.LoginPage" 4 x:Class="IndustrialControl.Pages.LoginPage"
5 Title="登录"> 5 Title="登录">
  6 +
  7 + <ContentPage.Resources>
  8 + <ResourceDictionary>
  9 + <conv:BoolNegationConverter x:Key="NotBool" />
  10 + <conv:EyeGlyphConverter x:Key="EyeGlyphConverter" />
  11 + </ResourceDictionary>
  12 + </ContentPage.Resources>
  13 +
6 <Grid Padding="24" RowDefinitions="Auto,*"> 14 <Grid Padding="24" RowDefinitions="Auto,*">
7 <VerticalStackLayout Spacing="12"> 15 <VerticalStackLayout Spacing="12">
  16 +
8 <Label Text="联创数智管家" FontAttributes="Bold" FontSize="28"/> 17 <Label Text="联创数智管家" FontAttributes="Bold" FontSize="28"/>
  18 +
9 <Entry Placeholder="请输入用户名" Text="{Binding UserName}"/> 19 <Entry Placeholder="请输入用户名" Text="{Binding UserName}"/>
10 - <Entry Placeholder="请输入登录密码" IsPassword="True" Text="{Binding Password}"/> 20 +
  21 + <!-- 密码输入 + 眼睛图标 -->
  22 + <Grid ColumnDefinitions="*,Auto">
  23 + <Entry
  24 + Grid.Column="0"
  25 + Placeholder="请输入登录密码"
  26 + Text="{Binding Password}"
  27 + IsPassword="{Binding ShowPassword, Converter={StaticResource NotBool}}" />
  28 +
  29 + <ImageButton Grid.Column="1"
  30 + VerticalOptions="Center"
  31 + BackgroundColor="Transparent"
  32 + Command="{Binding TogglePasswordCommand}">
  33 + <ImageButton.Source>
  34 + <FontImageSource Glyph="{Binding ShowPassword, Converter={StaticResource EyeGlyphConverter}}"
  35 + FontFamily="MaterialIcons" Color="Gray" Size="20"/>
  36 + </ImageButton.Source>
  37 + </ImageButton>
  38 + </Grid>
  39 +
11 <Button Text="登录" Command="{Binding LoginCommand}"/> 40 <Button Text="登录" Command="{Binding LoginCommand}"/>
12 </VerticalStackLayout> 41 </VerticalStackLayout>
13 </Grid> 42 </Grid>
1 { 1 {
2 - "schemaVersion": 3, 2 + "schemaVersion": 6,
3 "server": { 3 "server": {
4 - "ipAddress": "192.168.1.100",  
5 - "port": 8080 4 + "ipAddress": "114.250.20.230",
  5 + "port": 9128
6 }, 6 },
7 "apiEndpoints": { 7 "apiEndpoints": {
8 - "login": "/sso/login" 8 + "login": "/normalService/pda/auth/login"
9 }, 9 },
10 "logging": { 10 "logging": {
11 "level": "Information" 11 "level": "Information"
  1 +using IndustrialControl.ViewModels;
  2 +
1 namespace IndustrialControl.Services; 3 namespace IndustrialControl.Services;
2 4
3 public interface IWarehouseDataService 5 public interface IWarehouseDataService
@@ -8,6 +10,10 @@ public interface IWarehouseDataService @@ -8,6 +10,10 @@ public interface IWarehouseDataService
8 Task<SimpleOk> ConfirmInboundProductionAsync(string orderNo, IEnumerable<ScanItem> items); 10 Task<SimpleOk> ConfirmInboundProductionAsync(string orderNo, IEnumerable<ScanItem> items);
9 Task<SimpleOk> ConfirmOutboundMaterialAsync(string orderNo, IEnumerable<ScanItem> items); 11 Task<SimpleOk> ConfirmOutboundMaterialAsync(string orderNo, IEnumerable<ScanItem> items);
10 Task<SimpleOk> ConfirmOutboundFinishedAsync(string orderNo, IEnumerable<ScanItem> items); 12 Task<SimpleOk> ConfirmOutboundFinishedAsync(string orderNo, IEnumerable<ScanItem> items);
  13 + Task<IEnumerable<string>> ListInboundBinsAsync(string orderNo);
  14 +
  15 + // NEW: 查询列表(图1)
  16 + Task<IEnumerable<InboundOrderSummary>> ListInboundOrdersAsync(string? fuzzyOrderNo, DateTime? createdAt);
11 } 17 }
12 18
13 public record InboundOrder(string OrderNo, string Supplier, string LinkedNo, int ExpectedQty); 19 public record InboundOrder(string OrderNo, string Supplier, string LinkedNo, int ExpectedQty);
@@ -19,7 +25,7 @@ public class MockWarehouseDataService : IWarehouseDataService @@ -19,7 +25,7 @@ public class MockWarehouseDataService : IWarehouseDataService
19 private readonly Random _rand = new(); 25 private readonly Random _rand = new();
20 26
21 public Task<InboundOrder> GetInboundOrderAsync(string orderNo) 27 public Task<InboundOrder> GetInboundOrderAsync(string orderNo)
22 - => Task.FromResult(new InboundOrder(orderNo, "供应商/客户:XXXX", "关联单:CGD2736273", _rand.Next(10, 80))); 28 + => Task.FromResult(new InboundOrder(orderNo, "XXXX", "DHD_23326", _rand.Next(10, 80)));
23 29
24 public Task<SimpleOk> ConfirmInboundAsync(string orderNo, IEnumerable<ScanItem> items) 30 public Task<SimpleOk> ConfirmInboundAsync(string orderNo, IEnumerable<ScanItem> items)
25 => Task.FromResult(new SimpleOk(true, $"入库成功:{items.Count()} 条")); 31 => Task.FromResult(new SimpleOk(true, $"入库成功:{items.Count()} 条"));
@@ -32,4 +38,30 @@ public class MockWarehouseDataService : IWarehouseDataService @@ -32,4 +38,30 @@ public class MockWarehouseDataService : IWarehouseDataService
32 38
33 public Task<SimpleOk> ConfirmOutboundFinishedAsync(string orderNo, IEnumerable<ScanItem> items) 39 public Task<SimpleOk> ConfirmOutboundFinishedAsync(string orderNo, IEnumerable<ScanItem> items)
34 => Task.FromResult(new SimpleOk(true, $"成品出库成功:{items.Count()} 条")); 40 => Task.FromResult(new SimpleOk(true, $"成品出库成功:{items.Count()} 条"));
  41 + public Task<IEnumerable<InboundOrderSummary>> ListInboundOrdersAsync(string? fuzzyOrderNo, DateTime? createdAt)
  42 + {
  43 + // 模拟几条数据
  44 + var today = createdAt ?? DateTime.Today;
  45 + var samples = new List<InboundOrderSummary>
  46 + {
  47 + new InboundOrderSummary("CGD20250302001", "退料入库", "供应商A", today),
  48 + new InboundOrderSummary("CGD20250302002", "退库入库", "供应商B", today.AddDays(-1)),
  49 + new InboundOrderSummary("CGD20250302003", "采购入库", "供应商C", today.AddDays(-2))
  50 + };
  51 +
  52 + // 如果有模糊条件就过滤
  53 + if (!string.IsNullOrWhiteSpace(fuzzyOrderNo))
  54 + samples = samples
  55 + .Where(s => s.OrderNo.Contains(fuzzyOrderNo, StringComparison.OrdinalIgnoreCase))
  56 + .ToList();
  57 +
  58 + return Task.FromResult<IEnumerable<InboundOrderSummary>>(samples);
  59 + }
  60 + public Task<IEnumerable<string>> ListInboundBinsAsync(string orderNo)
  61 + {
  62 + var bins = new[] { "CK1_A201", "CK1_A202", "CK1_A203", "CK1_A204", "CK1_B101" };
  63 + return Task.FromResult<IEnumerable<string>>(bins);
  64 + }
  65 +
  66 +
35 } 67 }
  1 +using System.Threading.Tasks;
  2 +using Microsoft.Maui.Storage;
  3 +
  4 +namespace IndustrialControl;
  5 +
  6 +public static class TokenStorage
  7 +{
  8 + private const string Key = "auth_token";
  9 +
  10 + public static Task SaveAsync(string token)
  11 + => SecureStorage.SetAsync(Key, token);
  12 +
  13 + public static async Task<string?> GetAsync()
  14 + {
  15 + try
  16 + {
  17 + return await SecureStorage.GetAsync(Key);
  18 + }
  19 + catch
  20 + {
  21 + // 个别设备可能不支持安全存储,兜底返回 null
  22 + return null;
  23 + }
  24 + }
  25 + public static Task<string?> LoadAsync() =>
  26 + SecureStorage.GetAsync(Key);
  27 + public static Task ClearAsync()
  28 + => SecureStorage.SetAsync(Key, string.Empty); // 清空即可;也可用 Remove
  29 +
  30 +
  31 +}
1 using CommunityToolkit.Mvvm.ComponentModel; 1 using CommunityToolkit.Mvvm.ComponentModel;
2 using CommunityToolkit.Mvvm.Input; 2 using CommunityToolkit.Mvvm.Input;
3 -using IndustrialControl.Models;  
4 -using IndustrialControl.Services; 3 +using System.Text.Json.Nodes;
5 4
6 namespace IndustrialControl.ViewModels; 5 namespace IndustrialControl.ViewModels;
7 6
8 public partial class AdminViewModel : ObservableObject 7 public partial class AdminViewModel : ObservableObject
9 { 8 {
10 - private readonly ConfigLoader _cfg; 9 + private readonly IConfigLoader _cfg;
11 10
12 [ObservableProperty] private int schemaVersion; 11 [ObservableProperty] private int schemaVersion;
13 [ObservableProperty] private string ipAddress = ""; 12 [ObservableProperty] private string ipAddress = "";
14 [ObservableProperty] private int port; 13 [ObservableProperty] private int port;
15 [ObservableProperty] private string baseUrl = ""; 14 [ObservableProperty] private string baseUrl = "";
16 15
17 - public AdminViewModel(ConfigLoader cfg) 16 + public AdminViewModel(IConfigLoader cfg)
18 { 17 {
19 _cfg = cfg; 18 _cfg = cfg;
20 LoadFromConfig(); 19 LoadFromConfig();
21 - _cfg.ConfigChanged += () => LoadFromConfig();  
22 } 20 }
23 21
24 private void LoadFromConfig() 22 private void LoadFromConfig()
25 { 23 {
26 - var c = _cfg.Current;  
27 - SchemaVersion = c.SchemaVersion;  
28 - IpAddress = c.Server.IpAddress;  
29 - Port = c.Server.Port;  
30 - BaseUrl = _cfg.BaseUrl; 24 + JsonNode node = _cfg.Load();
  25 +
  26 + SchemaVersion = node["schemaVersion"]?.GetValue<int?>() ?? 0;
  27 +
  28 + var server = node["server"] as JsonObject ?? new JsonObject();
  29 + IpAddress = server["ipAddress"]?.GetValue<string>() ?? "";
  30 + Port = server["port"]?.GetValue<int?>() ?? 80;
  31 +
  32 + BaseUrl = $"http://{IpAddress}:{Port}";
31 } 33 }
32 34
33 [RelayCommand] 35 [RelayCommand]
34 - public async Task SaveAsync() 36 + public Task SaveAsync()
35 { 37 {
36 - var c = _cfg.Current;  
37 - c.Server.IpAddress = IpAddress.Trim();  
38 - c.Server.Port = Port;  
39 - await _cfg.SaveAsync(c);  
40 - BaseUrl = _cfg.BaseUrl;  
41 - await Shell.Current.DisplayAlert("已保存", "配置已保存,可立即生效。", "确定"); 38 + var node = _cfg.Load();
  39 +
  40 + var server = node["server"] as JsonObject ?? new JsonObject();
  41 + server["ipAddress"] = IpAddress.Trim();
  42 + server["port"] = Port;
  43 + node["server"] = server;
  44 +
  45 + _cfg.Save(node);
  46 +
  47 + BaseUrl = $"http://{IpAddress}:{Port}";
  48 + return Shell.Current.DisplayAlert("已保存", "配置已保存,可立即生效。", "确定");
42 } 49 }
43 50
44 [RelayCommand] 51 [RelayCommand]
45 public async Task ResetToPackageAsync() 52 public async Task ResetToPackageAsync()
46 { 53 {
47 - await _cfg.EnsureConfigIsLatestAsync();  
48 - await _cfg.ReloadAsync(); 54 + await _cfg.EnsureLatestAsync(); // 包内 schemaVersion 高则触发合并覆盖
  55 + LoadFromConfig();
49 await Shell.Current.DisplayAlert("已重载", "已从包内默认配置重载/合并。", "确定"); 56 await Shell.Current.DisplayAlert("已重载", "已从包内默认配置重载/合并。", "确定");
50 } 57 }
51 } 58 }
  1 +using CommunityToolkit.Mvvm.ComponentModel;
  2 +using CommunityToolkit.Mvvm.Input;
  3 +using IndustrialControl.Services;
  4 +using System.Collections.ObjectModel;
  5 +using System;
  6 +using IndustrialControl.Pages;
  7 +
  8 +namespace IndustrialControl.ViewModels;
  9 +
  10 +public partial class InboundMaterialSearchViewModel : ObservableObject
  11 +{
  12 + private readonly IWarehouseDataService _dataSvc;
  13 + [ObservableProperty] private string? searchOrderNo;
  14 + [ObservableProperty] private DateTime _createdDate = DateTime.Today;
  15 + [ObservableProperty] private InboundOrderSummary? selectedOrder;
  16 +
  17 + public InboundMaterialSearchViewModel(IWarehouseDataService dataSvc)
  18 + {
  19 + _dataSvc = dataSvc;
  20 + Orders = new ObservableCollection<InboundOrderSummary>();
  21 + }
  22 +
  23 +
  24 +
  25 + public ObservableCollection<InboundOrderSummary> Orders { get; }
  26 +
  27 + [RelayCommand]
  28 + private async Task SearchAsync()
  29 + {
  30 + Orders.Clear();
  31 + var list = await _dataSvc.ListInboundOrdersAsync(SearchOrderNo, CreatedDate);
  32 + foreach (var o in list)
  33 + Orders.Add(o);
  34 + }
  35 + // 打开明细(携带 orderNo 导航)
  36 + [RelayCommand]
  37 + private async Task OpenItemAsync(InboundOrderSummary item)
  38 + {
  39 + if (item is null) return;
  40 + await Shell.Current.GoToAsync(
  41 + $"{nameof(InboundMaterialPage)}?orderNo={Uri.EscapeDataString(item.OrderNo)}");
  42 + }
  43 +
  44 + [RelayCommand(CanExecute = nameof(CanGoInbound))]
  45 + private async Task GoInboundAsync()
  46 + {
  47 + if (SelectedOrder == null) return;
  48 + // 导航到原“入库明细/扫描”页面,并传入 orderNo
  49 + await Shell.Current.GoToAsync($"//InboundMaterial?orderNo={Uri.EscapeDataString(SelectedOrder.OrderNo)}");
  50 + }
  51 + public async Task LoadOrderAsync(string orderNo)
  52 + {
  53 + var order = await _dataSvc.GetInboundOrderAsync(orderNo);
  54 + // TODO: 将 order 映射到页面“基础信息”“待入库明细”等绑定源,保持你现有绑定字段/集合不变
  55 + }
  56 + private bool CanGoInbound() => SelectedOrder != null;
  57 +}
  58 +
  59 +/// <summary>用于列表显示的精简 DTO</summary>
  60 +public record InboundOrderSummary(
  61 + string OrderNo,
  62 + string InboundType,
  63 + string Supplier,
  64 + DateTime CreatedAt
  65 +);
@@ -16,7 +16,11 @@ namespace IndustrialControl.ViewModels @@ -16,7 +16,11 @@ namespace IndustrialControl.ViewModels
16 "CK1_A203", 16 "CK1_A203",
17 "CK1_A204" 17 "CK1_A204"
18 }; 18 };
  19 + public ObservableCollection<OutScannedItem> ScannedList { get; }
  20 + public ObservableCollection<PendingItem> PendingList { get; set; }
19 21
  22 + [ObservableProperty]
  23 + private OutScannedItem? selectedScanItem;
20 24
21 public InboundMaterialViewModel(IWarehouseDataService warehouseSvc) 25 public InboundMaterialViewModel(IWarehouseDataService warehouseSvc)
22 { 26 {
@@ -62,8 +66,7 @@ namespace IndustrialControl.ViewModels @@ -62,8 +66,7 @@ namespace IndustrialControl.ViewModels
62 [ObservableProperty] private string scannedTextColor = "#333333"; 66 [ObservableProperty] private string scannedTextColor = "#333333";
63 67
64 // 列表数据 68 // 列表数据
65 - public ObservableCollection<PendingItem> PendingList { get; set; }  
66 - public ObservableCollection<OutScannedItem> ScannedList { get; set; } 69 +
67 70
68 // 命令 71 // 命令
69 public IRelayCommand ShowPendingCommand { get; } 72 public IRelayCommand ShowPendingCommand { get; }
@@ -115,34 +118,63 @@ namespace IndustrialControl.ViewModels @@ -115,34 +118,63 @@ namespace IndustrialControl.ViewModels
115 ScannedList.Clear(); 118 ScannedList.Clear();
116 } 119 }
117 120
118 - /// <summary>  
119 - /// 扫描通过逻辑  
120 - /// </summary>  
121 - public void PassSelectedScan() 121 + [RelayCommand]
  122 + private async Task PassScan() // 绑定到 XAML 的 PassScanCommand
122 { 123 {
123 - foreach (var item in ScannedList)  
124 - {  
125 - if (item.IsSelected) 124 + var selected = ScannedList.Where(x => x.IsSelected).ToList();
  125 +
  126 + if (selected.Count == 0)
126 { 127 {
127 - // 这里可以做业务处理,比如更新状态 128 + await ShowTip("请先勾选一行记录。");
  129 + return;
128 } 130 }
  131 + if (selected.Count > 1)
  132 + {
  133 + await ShowTip("一次只能操作一行,请只勾选一行。");
  134 + return;
129 } 135 }
  136 +
  137 + var row = selected[0];
  138 + row.Qty += 1;
  139 +
  140 + // 确保本行已变绿(如果你的行颜色基于 IsSelected)
  141 + if (!row.IsSelected) row.IsSelected = true;
  142 + SelectedScanItem = row;
  143 +
130 } 144 }
131 145
132 - /// <summary>  
133 - /// 取消扫描逻辑  
134 - /// </summary>  
135 - public void CancelSelectedScan() 146 + [RelayCommand]
  147 + private async Task CancelScan() // 绑定到 XAML 的 CancelScanCommand
136 { 148 {
137 - for (int i = ScannedList.Count - 1; i >= 0; i--) 149 + var selected = ScannedList.Where(x => x.IsSelected).ToList();
  150 +
  151 + if (selected.Count == 0)
138 { 152 {
139 - if (ScannedList[i].IsSelected) 153 + await ShowTip("请先勾选一行记录。");
  154 + return;
  155 + }
  156 + if (selected.Count > 1)
140 { 157 {
141 - ScannedList.RemoveAt(i); 158 + await ShowTip("一次只能操作一行,请只勾选一行。");
  159 + return;
142 } 160 }
  161 +
  162 + var row = selected[0];
  163 + if (row.Qty <= 0)
  164 + {
  165 + await ShowTip("该行数量已为 0,无法再减少。");
  166 + return;
143 } 167 }
  168 +
  169 + row.Qty -= 1;
  170 +
  171 + // 如果希望数量减到 0 时取消高亮,可打开下一行
  172 + // if (row.Qty == 0) row.IsSelected = false;
144 } 173 }
145 174
  175 + private Task ShowTip(string message) =>
  176 + Shell.Current?.DisplayAlert("提示", message, "确定") ?? Task.CompletedTask;
  177 +
146 /// <summary> 178 /// <summary>
147 /// 确认入库逻辑 179 /// 确认入库逻辑
148 /// </summary> 180 /// </summary>
@@ -156,6 +188,55 @@ namespace IndustrialControl.ViewModels @@ -156,6 +188,55 @@ namespace IndustrialControl.ViewModels
156 188
157 return result.Succeeded; 189 return result.Succeeded;
158 } 190 }
  191 +
  192 + public async Task LoadOrderAsync(string orderNo)
  193 + {
  194 + // 0) 清空旧数据
  195 + ClearAll();
  196 +
  197 + // 1) 基础信息
  198 + var order = await _warehouseSvc.GetInboundOrderAsync(orderNo);
  199 + OrderNo = order.OrderNo;
  200 + Supplier = order.Supplier ?? string.Empty;
  201 + LinkedDeliveryNo = order.LinkedNo ?? string.Empty; // 到货/交货单
  202 + LinkedPurchaseNo = order is { } ? LinkedPurchaseNo : ""; // 你没有就留空
  203 +
  204 + // 2) 预入库库位:从接口取(先 mock),用于行内 Picker
  205 + // (你已经在 IWarehouseDataService 里加了 ListInboundBinsAsync)
  206 + try
  207 + {
  208 + var bins = await _warehouseSvc.ListInboundBinsAsync(orderNo);
  209 + AvailableBins.Clear();
  210 + foreach (var b in bins) AvailableBins.Add(b);
  211 + }
  212 + catch
  213 + {
  214 + // 兜底:本地 mock,避免空集合导致 Picker 无选项
  215 + AvailableBins.Clear();
  216 + foreach (var b in new[] { "CK1_A201", "CK1_A202", "CK1_A203", "CK1_A204" })
  217 + AvailableBins.Add(b);
  218 + }
  219 + var defaultBin = AvailableBins.FirstOrDefault() ?? "CK1_A201";
  220 +
  221 + // 3) 待入库明细:如果暂时没有后端接口,这里先 mock 多行,便于 UI 联调
  222 + PendingList.Clear();
  223 + // 你原来只加了 1 行,这里多给两行方便测试
  224 + PendingList.Add(new PendingItem { Name = "物料X", Spec = "型号X", PendingQty = order.ExpectedQty, Bin = defaultBin, ScannedQty = 0 });
  225 + PendingList.Add(new PendingItem { Name = "物料Y", Spec = "型号Y", PendingQty = Math.Max(10, order.ExpectedQty / 2), Bin = defaultBin, ScannedQty = 0 });
  226 + PendingList.Add(new PendingItem { Name = "物料Z", Spec = "型号Z", PendingQty = 8, Bin = defaultBin, ScannedQty = 0 });
  227 +
  228 + // 4) 扫描明细初始化(为空即可;也可以放 1~2 条便于测试)
  229 + ScannedList.Clear();
  230 + // 可选:放两条测试数据,验证“库位下拉/数量可编辑”
  231 + ScannedList.Add(new OutScannedItem { IsSelected = false, Barcode = "FSC2025060300001", Name = "物料X", Spec = "型号X", Bin = defaultBin, Qty = 1 });
  232 + ScannedList.Add(new OutScannedItem { IsSelected = false, Barcode = "FSC2025060300002", Name = "物料Y", Spec = "型号Y", Bin = defaultBin, Qty = 2 });
  233 +
  234 + // 5) 默认展示“待入库明细”页签
  235 + SwitchTab(true);
  236 + }
  237 +
  238 +
  239 +
159 } 240 }
160 241
161 // 待入库明细模型 242 // 待入库明细模型
@@ -169,14 +250,14 @@ namespace IndustrialControl.ViewModels @@ -169,14 +250,14 @@ namespace IndustrialControl.ViewModels
169 } 250 }
170 251
171 // 扫描明细模型 252 // 扫描明细模型
172 - public class OutScannedItem 253 + public partial class OutScannedItem : ObservableObject
173 { 254 {
174 - public bool IsSelected { get; set; }  
175 - public string Barcode { get; set; }  
176 - public string Name { get; set; }  
177 - public string Spec { get; set; }  
178 - public string Bin { get; set; }  
179 - public int Qty { get; set; } 255 + [ObservableProperty] private bool isSelected;
  256 + [ObservableProperty] private string barcode;
  257 + [ObservableProperty] private string name;
  258 + [ObservableProperty] private string spec;
  259 + [ObservableProperty] private string bin;
  260 + [ObservableProperty] private int qty;
180 } 261 }
181 262
182 } 263 }
1 using CommunityToolkit.Mvvm.ComponentModel; 1 using CommunityToolkit.Mvvm.ComponentModel;
2 using CommunityToolkit.Mvvm.Input; 2 using CommunityToolkit.Mvvm.Input;
  3 +using System.Net.Http.Json;
  4 +using System.Text.Json;
3 5
4 namespace IndustrialControl.ViewModels; 6 namespace IndustrialControl.ViewModels;
5 7
6 public partial class LoginViewModel : ObservableObject 8 public partial class LoginViewModel : ObservableObject
7 { 9 {
  10 + private readonly IConfigLoader _cfg;
  11 +
8 [ObservableProperty] private string userName = string.Empty; 12 [ObservableProperty] private string userName = string.Empty;
9 [ObservableProperty] private string password = string.Empty; 13 [ObservableProperty] private string password = string.Empty;
10 [ObservableProperty] private bool isBusy; 14 [ObservableProperty] private bool isBusy;
  15 + [ObservableProperty] private bool showPassword; // false=默认隐藏
  16 +
  17 + private static readonly JsonSerializerOptions _json = new() { PropertyNameCaseInsensitive = true };
  18 +
  19 + public LoginViewModel(IConfigLoader cfg)
  20 + {
  21 + _cfg = cfg;
  22 + }
11 23
12 [RelayCommand] 24 [RelayCommand]
13 public async Task LoginAsync() 25 public async Task LoginAsync()
14 { 26 {
15 - if (IsBusy) return; IsBusy = true; 27 + if (IsBusy) return;
  28 + IsBusy = true;
  29 +
16 try 30 try
17 { 31 {
18 - await Task.Delay(200); // mock  
19 - await Shell.Current.GoToAsync("//Home"); 32 + // 启动时已 EnsureLatestAsync,这里只读取 AppData 中已生效配置
  33 + var cfg = _cfg.Load();
  34 +
  35 + var host = cfg["server"]?["ipAddress"]?.GetValue<string>() ?? "127.0.0.1";
  36 + var port = cfg["server"]?["port"]?.GetValue<int?>() ?? 80;
  37 +
  38 + var path = cfg["apiEndpoints"]?["login"]?.GetValue<string>() ?? "normalService/pda/auth/login";
  39 + if (!path.StartsWith("/")) path = "/" + path;
  40 +
  41 + // 直接拼绝对 URL,避免修改 HttpClient.BaseAddress 引发异常
  42 + var fullUrl = new Uri($"http://{host}:{port}{path}");
  43 + System.Diagnostics.Debug.WriteLine($"[API] {fullUrl}");
  44 +
  45 + if (string.IsNullOrWhiteSpace(UserName) || string.IsNullOrWhiteSpace(Password))
  46 + {
  47 + await Application.Current.MainPage.DisplayAlert("提示", "请输入用户名和密码", "确定");
  48 + return;
  49 + }
  50 +
  51 + var payload = new { username = UserName, password = Password };
  52 + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
  53 +
  54 + var resp = await ApiClient.Instance.PostAsJsonAsync(fullUrl, payload, cts.Token);
  55 +
  56 + if (!resp.IsSuccessStatusCode)
  57 + {
  58 + var raw = await resp.Content.ReadAsStringAsync(cts.Token);
  59 + await Application.Current.MainPage.DisplayAlert("登录失败",
  60 + $"HTTP {(int)resp.StatusCode}: {raw}", "确定");
  61 + return;
  62 + }
  63 +
  64 + var result = await resp.Content.ReadFromJsonAsync<ApiResponse<LoginResult>>(_json, cts.Token);
  65 + bool ok = (result?.success == true) || (result?.code is 0 or 200);
  66 + var token = result?.result?.token;
  67 +
  68 + if (ok && !string.IsNullOrWhiteSpace(token))
  69 + {
  70 + await TokenStorage.SaveAsync(token!);
  71 + ApiClient.SetBearer(token);
  72 + if (ok && !string.IsNullOrWhiteSpace(token))
  73 + {
  74 + await TokenStorage.SaveAsync(token);
  75 + ApiClient.SetBearer(token);
  76 +
  77 + App.SwitchToLoggedInShell();
  78 + }
  79 +
  80 + }
  81 + else
  82 + {
  83 + await Application.Current.MainPage.DisplayAlert("登录失败", result?.message ?? "登录返回无效", "确定");
  84 + }
  85 + }
  86 + catch (OperationCanceledException)
  87 + {
  88 + await Application.Current.MainPage.DisplayAlert("超时", "登录请求超时,请检查网络", "确定");
20 } 89 }
21 catch (Exception ex) 90 catch (Exception ex)
22 { 91 {
23 - await Application.Current.MainPage.DisplayAlert("登录失败", ex.Message, "确定"); 92 + await Application.Current.MainPage.DisplayAlert("异常", ex.Message, "确定");
  93 + }
  94 + finally
  95 + {
  96 + IsBusy = false;
  97 + }
  98 + }
  99 +
  100 +
  101 + [RelayCommand]
  102 + private void TogglePassword() => ShowPassword = !ShowPassword;
  103 +
  104 + private sealed class ApiResponse<T>
  105 + {
  106 + public int code { get; set; }
  107 + public bool success { get; set; }
  108 + public string? message { get; set; }
  109 + public T? result { get; set; }
24 } 110 }
25 - finally { IsBusy = false; } 111 +
  112 + private sealed class LoginResult
  113 + {
  114 + public string? token { get; set; }
  115 + public object? userInfo { get; set; }
26 } 116 }
27 } 117 }