作者 李壮

inbound material page

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