This commit is contained in:
empty
2025-12-17 13:56:00 +08:00
parent 5366c56830
commit 473b44510d
41 changed files with 4620 additions and 0 deletions

19
PackagingMallShipper.sln Normal file
View File

@@ -0,0 +1,19 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.30114.105
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PackagingMallShipper", "PackagingMallShipper\PackagingMallShipper.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
<appSettings>
<!-- API工厂配置 -->
<add key="ApiBaseUrl" value="https://user.api.it120.cc" />
<add key="SubDomain" value="vv125s" />
<!-- 同步配置 -->
<add key="SyncPageSize" value="50" />
<add key="ShipConcurrency" value="3" />
<!-- Token有效期小时 -->
<add key="TokenExpireHours" value="24" />
</appSettings>
</configuration>

View File

@@ -0,0 +1,13 @@
<Application x:Class="PackagingMallShipper.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:PackagingMallShipper"
StartupUri="Views/LoginWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,25 @@
using System;
using System.Windows;
using PackagingMallShipper.Data;
namespace PackagingMallShipper
{
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
try
{
SqliteHelper.Initialize();
}
catch (Exception ex)
{
MessageBox.Show($"数据库初始化失败: {ex.Message}", "错误",
MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown(1);
}
}
}
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace PackagingMallShipper.Converters
{
public class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolValue)
{
return boolValue ? Visibility.Visible : Visibility.Collapsed;
}
return Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is Visibility visibility)
{
return visibility == Visibility.Visible;
}
return false;
}
}
public class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolValue)
{
return !boolValue;
}
return true;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolValue)
{
return !boolValue;
}
return false;
}
}
public class StringToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string strValue)
{
return string.IsNullOrEmpty(strValue) ? Visibility.Collapsed : Visibility.Visible;
}
return Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace PackagingMallShipper.Converters
{
public class StatusToColorConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is int status)
{
switch (status)
{
case -1: return new SolidColorBrush(Color.FromRgb(0x99, 0x99, 0x99)); // 已关闭 - 灰色
case 0: return new SolidColorBrush(Color.FromRgb(0xFF, 0x4D, 0x4F)); // 待支付 - 红色
case 1: return new SolidColorBrush(Color.FromRgb(0xFA, 0x8C, 0x16)); // 待发货 - 橙色
case 2: return new SolidColorBrush(Color.FromRgb(0x18, 0x90, 0xFF)); // 待收货 - 蓝色
case 3: return new SolidColorBrush(Color.FromRgb(0x72, 0x2E, 0xD1)); // 待评价 - 紫色
case 4: return new SolidColorBrush(Color.FromRgb(0x52, 0xC4, 0x1A)); // 已完成 - 绿色
default: return new SolidColorBrush(Colors.Black);
}
}
return new SolidColorBrush(Colors.Black);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Globalization;
using System.Windows.Data;
using PackagingMallShipper.Models;
namespace PackagingMallShipper.Converters
{
public class StatusToTextConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is int status)
{
return OrderStatus.GetStatusText(status);
}
return "未知";
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,94 @@
-- 包装商城发货助手 - SQLite数据库初始化脚本
-- 1. 本地用户会话
CREATE TABLE IF NOT EXISTS local_session (
id INTEGER PRIMARY KEY,
mobile TEXT NOT NULL,
token TEXT NOT NULL,
uid INTEGER,
nickname TEXT,
enterprise_id INTEGER,
last_login_at DATETIME DEFAULT CURRENT_TIMESTAMP,
token_expires_at DATETIME
);
-- 2. 订单缓存表
CREATE TABLE IF NOT EXISTS orders_cache (
id INTEGER PRIMARY KEY,
order_number TEXT NOT NULL UNIQUE,
status INTEGER NOT NULL,
amount REAL,
amount_real REAL,
uid INTEGER,
user_mobile TEXT,
logistics_name TEXT,
logistics_mobile TEXT,
logistics_province TEXT,
logistics_city TEXT,
logistics_district TEXT,
logistics_address TEXT,
goods_json TEXT,
express_company_id INTEGER,
express_company_name TEXT,
tracking_number TEXT,
date_ship DATETIME,
sync_status TEXT DEFAULT 'synced',
local_updated_at DATETIME,
date_add DATETIME,
date_pay DATETIME,
date_update DATETIME,
synced_at DATETIME
);
-- 3. 发货队列表
CREATE TABLE IF NOT EXISTS ship_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
order_number TEXT NOT NULL,
express_company_id INTEGER NOT NULL,
tracking_number TEXT NOT NULL,
status TEXT DEFAULT 'pending',
retry_count INTEGER DEFAULT 0,
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME
);
-- 4. 同步日志表
CREATE TABLE IF NOT EXISTS sync_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sync_type TEXT NOT NULL,
sync_mode TEXT,
sync_start DATETIME NOT NULL,
sync_end DATETIME,
total_count INTEGER DEFAULT 0,
new_count INTEGER DEFAULT 0,
updated_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
status TEXT NOT NULL,
error_message TEXT
);
-- 5. 操作日志表
CREATE TABLE IF NOT EXISTS operation_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operation_type TEXT NOT NULL,
target_id INTEGER,
target_number TEXT,
details TEXT,
result TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders_cache(status);
CREATE INDEX IF NOT EXISTS idx_orders_date_add ON orders_cache(date_add);
CREATE INDEX IF NOT EXISTS idx_orders_order_number ON orders_cache(order_number);
CREATE INDEX IF NOT EXISTS idx_ship_queue_status ON ship_queue(status);
CREATE INDEX IF NOT EXISTS idx_sync_logs_status ON sync_logs(status);

View File

@@ -0,0 +1,118 @@
using System;
using System.Data.SQLite;
using System.IO;
using PackagingMallShipper.Helpers;
namespace PackagingMallShipper.Data
{
public static class SqliteHelper
{
private static string _connectionString;
private static string _dbPath;
public static void Initialize()
{
_dbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PackagingMallShipper",
"data.db"
);
var dir = Path.GetDirectoryName(_dbPath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
_connectionString = $"Data Source={_dbPath};Version=3;";
if (!File.Exists(_dbPath))
{
SQLiteConnection.CreateFile(_dbPath);
CreateTables();
}
}
public static SQLiteConnection GetConnection()
{
return new SQLiteConnection(_connectionString);
}
public static string DbPath => _dbPath;
private static void CreateTables()
{
using (var conn = GetConnection())
{
conn.Open();
var sql = ResourceHelper.GetEmbeddedResource("schema.sql");
using (var cmd = new SQLiteCommand(sql, conn))
{
cmd.ExecuteNonQuery();
}
}
}
public static void ExecuteNonQuery(string sql, params SQLiteParameter[] parameters)
{
using (var conn = GetConnection())
{
conn.Open();
using (var cmd = new SQLiteCommand(sql, conn))
{
if (parameters != null)
cmd.Parameters.AddRange(parameters);
cmd.ExecuteNonQuery();
}
}
}
public static T ExecuteScalar<T>(string sql, params SQLiteParameter[] parameters)
{
using (var conn = GetConnection())
{
conn.Open();
using (var cmd = new SQLiteCommand(sql, conn))
{
if (parameters != null)
cmd.Parameters.AddRange(parameters);
var result = cmd.ExecuteScalar();
if (result == null || result == DBNull.Value)
return default(T);
return (T)Convert.ChangeType(result, typeof(T));
}
}
}
}
public static class DataReaderExtensions
{
public static string GetStringSafe(this SQLiteDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? "" : reader.GetString(ordinal);
}
public static int GetInt32Safe(this SQLiteDataReader reader, string column, int defaultValue = 0)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? defaultValue : reader.GetInt32(ordinal);
}
public static decimal GetDecimalSafe(this SQLiteDataReader reader, string column, decimal defaultValue = 0)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? defaultValue : reader.GetDecimal(ordinal);
}
public static DateTime? GetDateTimeSafe(this SQLiteDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? (DateTime?)null : reader.GetDateTime(ordinal);
}
public static int? GetInt32Nullable(this SQLiteDataReader reader, string column)
{
var ordinal = reader.GetOrdinal(column);
return reader.IsDBNull(ordinal) ? (int?)null : reader.GetInt32(ordinal);
}
}
}

View File

@@ -0,0 +1,27 @@
using System.Configuration;
namespace PackagingMallShipper.Helpers
{
public static class AppConfig
{
public static string ApiBaseUrl =>
ConfigurationManager.AppSettings["ApiBaseUrl"] ?? "https://user.api.it120.cc";
public static string SubDomain =>
ConfigurationManager.AppSettings["SubDomain"] ?? "vv125s";
public static int SyncPageSize =>
int.TryParse(ConfigurationManager.AppSettings["SyncPageSize"], out var size) ? size : 50;
public static int ShipConcurrency =>
int.TryParse(ConfigurationManager.AppSettings["ShipConcurrency"], out var c) ? c : 3;
public static int TokenExpireHours =>
int.TryParse(ConfigurationManager.AppSettings["TokenExpireHours"], out var h) ? h : 24;
public static string GetApiUrl(string endpoint)
{
return $"{ApiBaseUrl}/{SubDomain}{endpoint}";
}
}
}

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Linq;
namespace PackagingMallShipper.Helpers
{
public static class ExpressCompanies
{
public static readonly List<ExpressCompany> All = new List<ExpressCompany>
{
new ExpressCompany(1, "顺丰速运", "SF"),
new ExpressCompany(2, "中通快递", "ZTO"),
new ExpressCompany(3, "圆通速递", "YTO"),
new ExpressCompany(4, "韵达快递", "YD"),
new ExpressCompany(5, "申通快递", "STO"),
new ExpressCompany(6, "极兔速递", "JTSD"),
new ExpressCompany(7, "邮政快递包裹", "YZPY"),
new ExpressCompany(8, "EMS", "EMS"),
new ExpressCompany(9, "京东快递", "JD"),
new ExpressCompany(10, "德邦快递", "DBL"),
new ExpressCompany(-1, "其他/自配送", "OTHER")
};
public static string GetName(int id)
{
return All.FirstOrDefault(e => e.Id == id)?.Name ?? "其他";
}
public static int GetIdByName(string name)
{
if (string.IsNullOrEmpty(name)) return -1;
var express = All.FirstOrDefault(e =>
e.Name.Contains(name) || name.Contains(e.Name));
return express?.Id ?? -1;
}
public static ExpressCompany GetById(int id)
{
return All.FirstOrDefault(e => e.Id == id) ?? All.Last();
}
}
public class ExpressCompany
{
public int Id { get; }
public string Name { get; }
public string Code { get; }
public ExpressCompany(int id, string name, string code)
{
Id = id;
Name = name;
Code = code;
}
public override string ToString() => Name;
}
}

View File

@@ -0,0 +1,25 @@
using System.IO;
using System.Reflection;
namespace PackagingMallShipper.Helpers
{
public static class ResourceHelper
{
public static string GetEmbeddedResource(string resourceName)
{
var assembly = Assembly.GetExecutingAssembly();
var fullName = $"PackagingMallShipper.Data.Resources.{resourceName}";
using (var stream = assembly.GetManifestResourceStream(fullName))
{
if (stream == null)
throw new FileNotFoundException($"嵌入资源未找到: {fullName}");
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
}
}
}

View File

@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace PackagingMallShipper.Models
{
public class ApiResponse<T>
{
[JsonProperty("code")]
public int Code { get; set; }
[JsonProperty("msg")]
public string Msg { get; set; }
[JsonProperty("data")]
public T Data { get; set; }
}
public class LoginData
{
[JsonProperty("token")]
public string Token { get; set; }
[JsonProperty("uid")]
public int Uid { get; set; }
}
public class UserInfo
{
[JsonProperty("nick")]
public string Nick { get; set; }
[JsonProperty("mobile")]
public string Mobile { get; set; }
[JsonProperty("avatarUrl")]
public string AvatarUrl { get; set; }
}
public class LoginResult
{
public bool Success { get; set; }
public string Token { get; set; }
public string Message { get; set; }
public UserInfo UserInfo { get; set; }
}
public class OrderListData
{
[JsonProperty("orderList")]
public List<OrderDto> OrderList { get; set; }
[JsonProperty("totalRow")]
public int TotalRow { get; set; }
[JsonProperty("totalPage")]
public int TotalPage { get; set; }
}
public class OrderDto
{
[JsonProperty("id")]
public int Id { get; set; }
[JsonProperty("orderNumber")]
public string OrderNumber { get; set; }
[JsonProperty("status")]
public int Status { get; set; }
[JsonProperty("amount")]
public decimal Amount { get; set; }
[JsonProperty("amountReal")]
public decimal AmountReal { get; set; }
[JsonProperty("uid")]
public int Uid { get; set; }
[JsonProperty("userMobile")]
public string UserMobile { get; set; }
[JsonProperty("linkMan")]
public string LogisticsName { get; set; }
[JsonProperty("mobile")]
public string LogisticsMobile { get; set; }
[JsonProperty("provinceStr")]
public string LogisticsProvince { get; set; }
[JsonProperty("cityStr")]
public string LogisticsCity { get; set; }
[JsonProperty("areaStr")]
public string LogisticsDistrict { get; set; }
[JsonProperty("address")]
public string LogisticsAddress { get; set; }
[JsonProperty("goods")]
public List<GoodsItem> Goods { get; set; }
[JsonProperty("shipperId")]
public int? ShipperId { get; set; }
[JsonProperty("shipperCode")]
public string ShipperCode { get; set; }
[JsonProperty("dateShip")]
public DateTime? DateShip { get; set; }
[JsonProperty("dateAdd")]
public DateTime DateAdd { get; set; }
[JsonProperty("datePay")]
public DateTime? DatePay { get; set; }
[JsonProperty("dateUpdate")]
public DateTime DateUpdate { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
using System;
namespace PackagingMallShipper.Models
{
public class LocalSession
{
public int Id { get; set; }
public string Mobile { get; set; }
public string Token { get; set; }
public int Uid { get; set; }
public string Nickname { get; set; }
public int? EnterpriseId { get; set; }
public DateTime LastLoginAt { get; set; }
public DateTime TokenExpiresAt { get; set; }
}
}

View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace PackagingMallShipper.Models
{
public class Order
{
public int Id { get; set; }
public string OrderNumber { get; set; }
public int Status { get; set; }
public decimal Amount { get; set; }
public decimal AmountReal { get; set; }
public int Uid { get; set; }
public string UserMobile { get; set; }
public string LogisticsName { get; set; }
public string LogisticsMobile { get; set; }
public string LogisticsProvince { get; set; }
public string LogisticsCity { get; set; }
public string LogisticsDistrict { get; set; }
public string LogisticsAddress { get; set; }
public string GoodsJson { get; set; }
public int? ExpressCompanyId { get; set; }
public string ExpressCompanyName { get; set; }
public string TrackingNumber { get; set; }
public DateTime? DateShip { get; set; }
public string SyncStatus { get; set; }
public DateTime? LocalUpdatedAt { get; set; }
public DateTime? DateAdd { get; set; }
public DateTime? DatePay { get; set; }
public DateTime? DateUpdate { get; set; }
public DateTime? SyncedAt { get; set; }
public bool IsSelected { get; set; }
public string FullAddress => $"{LogisticsProvince}{LogisticsCity}{LogisticsDistrict}{LogisticsAddress}";
public string StatusText => OrderStatus.GetStatusText(Status);
public string GoodsInfo
{
get
{
if (string.IsNullOrEmpty(GoodsJson)) return "";
try
{
var goods = JsonConvert.DeserializeObject<List<GoodsItem>>(GoodsJson);
return string.Join("; ", goods.ConvertAll(g => $"{g.GoodsName}x{g.Number}"));
}
catch
{
return "";
}
}
}
}
public class GoodsItem
{
[JsonProperty("goodsName")]
public string GoodsName { get; set; }
[JsonProperty("number")]
public int Number { get; set; }
[JsonProperty("price")]
public decimal Price { get; set; }
}
public static class OrderStatus
{
public static readonly Dictionary<int, string> Map = new Dictionary<int, string>
{
{ -1, "已关闭" },
{ 0, "待支付" },
{ 1, "待发货" },
{ 2, "待收货" },
{ 3, "待评价" },
{ 4, "已完成" }
};
public static string GetStatusText(int status)
{
return Map.TryGetValue(status, out var text) ? text : "未知";
}
}
public enum SyncStatusEnum
{
Synced,
PendingShip,
Shipping,
Failed
}
}

View File

@@ -0,0 +1,43 @@
using System.Collections.Generic;
namespace PackagingMallShipper.Models
{
public class ShipOrderRequest
{
public int OrderId { get; set; }
public string OrderNumber { get; set; }
public int ExpressCompanyId { get; set; }
public string TrackingNumber { get; set; }
}
public class ShipResult
{
public bool Success { get; set; }
public string Message { get; set; }
}
public class ShipOrderResult
{
public int OrderId { get; set; }
public string OrderNumber { get; set; }
public bool Success { get; set; }
public string Error { get; set; }
}
public class BatchShipResult
{
public int Total { get; set; }
public int SuccessCount { get; set; }
public int FailedCount { get; set; }
public List<ShipOrderResult> Results { get; set; } = new List<ShipOrderResult>();
}
public class ImportShipResult
{
public int TotalRows { get; set; }
public int ValidOrders { get; set; }
public int SuccessCount { get; set; }
public int FailedCount { get; set; }
public List<string> Errors { get; set; } = new List<string>();
}
}

View File

@@ -0,0 +1,33 @@
using System;
namespace PackagingMallShipper.Models
{
public class SyncLog
{
public int Id { get; set; }
public string SyncType { get; set; }
public string SyncMode { get; set; }
public DateTime SyncStart { get; set; }
public DateTime? SyncEnd { get; set; }
public int TotalCount { get; set; }
public int NewCount { get; set; }
public int UpdatedCount { get; set; }
public int FailedCount { get; set; }
public string Status { get; set; }
public string ErrorMessage { get; set; }
}
public class SyncResult
{
public int TotalCount { get; set; }
public int NewCount { get; set; }
public int UpdatedCount { get; set; }
public int FailedCount { get; set; }
}
public enum SyncMode
{
Full,
Incremental
}
}

View File

@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>PackagingMallShipper</RootNamespace>
<AssemblyName>PackagingMallShipper</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<WarningLevel>4</WarningLevel>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<LangVersion>7.3</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<ApplicationIcon>Resources\Icons\app.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
<Reference Include="System.Xaml">
<RequiredTargetFramework>4.0</RequiredTargetFramework>
</Reference>
<Reference Include="WindowsBase" />
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="App.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</ApplicationDefinition>
<Page Include="Views\MainWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\LoginWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\OrderListView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\ShippingDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Resources\Styles.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Compile Include="App.xaml.cs">
<DependentUpon>App.xaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Include="Converters\BoolToVisibilityConverter.cs" />
<Compile Include="Converters\StatusToColorConverter.cs" />
<Compile Include="Converters\StatusToTextConverter.cs" />
<Compile Include="Data\SqliteHelper.cs" />
<Compile Include="Helpers\AppConfig.cs" />
<Compile Include="Helpers\ExpressCompanies.cs" />
<Compile Include="Helpers\ResourceHelper.cs" />
<Compile Include="Models\ApiResponses.cs" />
<Compile Include="Models\LocalSession.cs" />
<Compile Include="Models\Order.cs" />
<Compile Include="Models\ShipModels.cs" />
<Compile Include="Models\SyncLog.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Services\AuthService.cs" />
<Compile Include="Services\ExcelService.cs" />
<Compile Include="Services\Interfaces.cs" />
<Compile Include="Services\OrderService.cs" />
<Compile Include="Services\ShipService.cs" />
<Compile Include="Services\SyncService.cs" />
<Compile Include="ViewModels\LoginViewModel.cs" />
<Compile Include="ViewModels\MainViewModel.cs" />
<Compile Include="ViewModels\OrderListViewModel.cs" />
<Compile Include="ViewModels\ViewModelBase.cs" />
<Compile Include="Views\LoginWindow.xaml.cs">
<DependentUpon>LoginWindow.xaml</DependentUpon>
</Compile>
<Compile Include="Views\MainWindow.xaml.cs">
<DependentUpon>MainWindow.xaml</DependentUpon>
</Compile>
<Compile Include="Views\OrderListView.xaml.cs">
<DependentUpon>OrderListView.xaml</DependentUpon>
</Compile>
<Compile Include="Views\ShippingDialog.xaml.cs">
<DependentUpon>ShippingDialog.xaml</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Data\Resources\schema.sql" />
</ItemGroup>
<ItemGroup>
<Resource Include="Resources\Icons\app.ico" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="System.Data.SQLite" Version="1.0.118" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="ClosedXML" Version="0.102.2" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@@ -0,0 +1,22 @@
using System.Reflection;
using System.Runtime.InteropServices;
using System.Windows;
[assembly: AssemblyTitle("PackagingMallShipper")]
[assembly: AssemblyDescription("包装商城发货助手 - 轻量级订单发货客户端")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("PackagingMallShipper")]
[assembly: AssemblyCopyright("Copyright © 2025")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
[assembly: ComVisible(false)]
[assembly: ThemeInfo(
ResourceDictionaryLocation.None,
ResourceDictionaryLocation.SourceAssembly
)]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

View File

@@ -0,0 +1,38 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace PackagingMallShipper.Properties {
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.0.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
public static Settings Default {
get {
return defaultInstance;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("")]
public string SavedMobile {
get {
return ((string)(this["SavedMobile"]));
}
set {
this["SavedMobile"] = value;
}
}
}
}

View File

@@ -0,0 +1,9 @@
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)" GeneratedClassNamespace="PackagingMallShipper.Properties" GeneratedClassName="Settings">
<Profiles />
<Settings>
<Setting Name="SavedMobile" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
</Settings>
</SettingsFile>

View File

@@ -0,0 +1,99 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:PackagingMallShipper.Converters">
<!-- 转换器 -->
<converters:BoolToVisibilityConverter x:Key="BoolToVisibility"/>
<converters:InverseBoolConverter x:Key="InverseBool"/>
<converters:StringToVisibilityConverter x:Key="StringToVisibility"/>
<converters:StatusToColorConverter x:Key="StatusToColor"/>
<converters:StatusToTextConverter x:Key="StatusToText"/>
<!-- 颜色定义 -->
<SolidColorBrush x:Key="PrimaryColor" Color="#1890FF"/>
<SolidColorBrush x:Key="SuccessColor" Color="#52C41A"/>
<SolidColorBrush x:Key="WarningColor" Color="#FA8C16"/>
<SolidColorBrush x:Key="DangerColor" Color="#FF4D4F"/>
<SolidColorBrush x:Key="TextColor" Color="#333333"/>
<SolidColorBrush x:Key="SecondaryTextColor" Color="#666666"/>
<SolidColorBrush x:Key="BorderColor" Color="#D9D9D9"/>
<SolidColorBrush x:Key="BackgroundColor" Color="#F5F5F5"/>
<!-- 按钮样式 -->
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource PrimaryColor}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="15,8"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#40A9FF"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#096DD9"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="#BFBFBF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 文本框样式 -->
<Style TargetType="TextBox">
<Setter Property="BorderBrush" Value="{StaticResource BorderColor}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,5"/>
<Style.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource PrimaryColor}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- 下拉框样式 -->
<Style TargetType="ComboBox">
<Setter Property="Padding" Value="8,5"/>
</Style>
<!-- DataGrid样式 -->
<Style TargetType="DataGrid">
<Setter Property="Background" Value="White"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderColor}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="RowBackground" Value="White"/>
<Setter Property="AlternatingRowBackground" Value="#FAFAFA"/>
</Style>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="#FAFAFA"/>
<Setter Property="Foreground" Value="{StaticResource TextColor}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderColor}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
</Style>
<Style TargetType="DataGridCell">
<Setter Property="Padding" Value="10,5"/>
<Setter Property="BorderThickness" Value="0"/>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="#E6F7FF"/>
<Setter Property="Foreground" Value="{StaticResource TextColor}"/>
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,162 @@
using System;
using System.Data.SQLite;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using PackagingMallShipper.Data;
using PackagingMallShipper.Helpers;
using PackagingMallShipper.Models;
namespace PackagingMallShipper.Services
{
public class AuthService : IAuthService
{
private readonly HttpClient _httpClient;
private LocalSession _currentSession;
public AuthService()
{
_httpClient = new HttpClient();
_httpClient.Timeout = TimeSpan.FromSeconds(30);
LoadSession();
}
public LocalSession CurrentSession => _currentSession;
public bool IsLoggedIn => !string.IsNullOrEmpty(GetToken());
public async Task<LoginResult> LoginAsync(string mobile, string password)
{
try
{
var url = AppConfig.GetApiUrl($"/user/m/login?mobile={mobile}&pwd={password}");
var response = await _httpClient.PostAsync(url, null);
var json = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<ApiResponse<LoginData>>(json);
if (result?.Code != 0)
{
return new LoginResult
{
Success = false,
Message = result?.Msg ?? "登录失败"
};
}
var token = result.Data.Token;
var uid = result.Data.Uid;
_httpClient.DefaultRequestHeaders.Clear();
_httpClient.DefaultRequestHeaders.Add("X-Token", token);
var userInfoUrl = AppConfig.GetApiUrl("/user/detail");
var userResponse = await _httpClient.GetAsync(userInfoUrl);
var userJson = await userResponse.Content.ReadAsStringAsync();
var userResult = JsonConvert.DeserializeObject<ApiResponse<UserInfo>>(userJson);
_currentSession = new LocalSession
{
Id = 1,
Mobile = mobile,
Token = token,
Uid = uid,
Nickname = userResult?.Data?.Nick ?? mobile,
LastLoginAt = DateTime.Now,
TokenExpiresAt = DateTime.Now.AddHours(AppConfig.TokenExpireHours)
};
SaveSession(_currentSession);
return new LoginResult
{
Success = true,
Token = token,
UserInfo = userResult?.Data
};
}
catch (Exception ex)
{
return new LoginResult
{
Success = false,
Message = $"网络错误: {ex.Message}"
};
}
}
public string GetToken()
{
if (_currentSession == null)
LoadSession();
if (_currentSession == null)
return null;
if (_currentSession.TokenExpiresAt < DateTime.Now)
return null;
return _currentSession.Token;
}
public void Logout()
{
_currentSession = null;
SqliteHelper.ExecuteNonQuery("DELETE FROM local_session WHERE id = 1");
}
private void SaveSession(LocalSession session)
{
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var sql = @"INSERT OR REPLACE INTO local_session
(id, mobile, token, uid, nickname, last_login_at, token_expires_at)
VALUES (@id, @mobile, @token, @uid, @nickname, @lastLogin, @expires)";
using (var cmd = new SQLiteCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@id", session.Id);
cmd.Parameters.AddWithValue("@mobile", session.Mobile);
cmd.Parameters.AddWithValue("@token", session.Token);
cmd.Parameters.AddWithValue("@uid", session.Uid);
cmd.Parameters.AddWithValue("@nickname", session.Nickname ?? "");
cmd.Parameters.AddWithValue("@lastLogin", session.LastLoginAt);
cmd.Parameters.AddWithValue("@expires", session.TokenExpiresAt);
cmd.ExecuteNonQuery();
}
}
}
private void LoadSession()
{
try
{
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var sql = "SELECT * FROM local_session WHERE id = 1";
using (var cmd = new SQLiteCommand(sql, conn))
using (var reader = cmd.ExecuteReader())
{
if (reader.Read())
{
_currentSession = new LocalSession
{
Id = reader.GetInt32(reader.GetOrdinal("id")),
Mobile = reader.GetStringSafe("mobile"),
Token = reader.GetStringSafe("token"),
Uid = reader.GetInt32Safe("uid"),
Nickname = reader.GetStringSafe("nickname"),
LastLoginAt = reader.GetDateTimeSafe("last_login_at") ?? DateTime.MinValue,
TokenExpiresAt = reader.GetDateTimeSafe("token_expires_at") ?? DateTime.MinValue
};
}
}
}
}
catch
{
_currentSession = null;
}
}
}
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ClosedXML.Excel;
using PackagingMallShipper.Helpers;
using PackagingMallShipper.Models;
namespace PackagingMallShipper.Services
{
public class ExcelService : IExcelService
{
private readonly IOrderService _orderService;
private readonly IShipService _shipService;
public ExcelService(IOrderService orderService, IShipService shipService)
{
_orderService = orderService;
_shipService = shipService;
}
public async Task<int> ExportPendingOrdersAsync(string filePath)
{
var orders = await _orderService.GetOrdersAsync(status: 1);
using (var workbook = new XLWorkbook())
{
var worksheet = workbook.Worksheets.Add("待发货订单");
var headers = new[]
{
"订单号", "下单时间", "收件人", "联系电话",
"省份", "城市", "区县", "详细地址",
"商品信息", "订单金额", "快递公司", "快递单号"
};
for (int i = 0; i < headers.Length; i++)
{
var cell = worksheet.Cell(1, i + 1);
cell.Value = headers[i];
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = XLColor.LightGray;
}
for (int i = 0; i < orders.Count; i++)
{
var order = orders[i];
var row = i + 2;
worksheet.Cell(row, 1).Value = order.OrderNumber;
worksheet.Cell(row, 2).Value = order.DateAdd?.ToString("yyyy-MM-dd HH:mm:ss");
worksheet.Cell(row, 3).Value = order.LogisticsName;
worksheet.Cell(row, 4).Value = order.LogisticsMobile;
worksheet.Cell(row, 4).SetDataType(XLDataType.Text);
worksheet.Cell(row, 5).Value = order.LogisticsProvince;
worksheet.Cell(row, 6).Value = order.LogisticsCity;
worksheet.Cell(row, 7).Value = order.LogisticsDistrict;
worksheet.Cell(row, 8).Value = order.LogisticsAddress;
worksheet.Cell(row, 9).Value = order.GoodsInfo;
worksheet.Cell(row, 10).Value = order.AmountReal;
worksheet.Cell(row, 11).Value = "";
worksheet.Cell(row, 12).Value = "";
}
worksheet.Column(1).Width = 20;
worksheet.Column(2).Width = 18;
worksheet.Column(4).Width = 15;
worksheet.Column(8).Width = 40;
worksheet.Column(9).Width = 30;
worksheet.Column(12).Width = 20;
worksheet.SheetView.FreezeRows(1);
workbook.SaveAs(filePath);
}
return orders.Count;
}
public async Task<ImportShipResult> ImportAndShipAsync(string filePath)
{
var ordersToShip = new List<ShipOrderRequest>();
int totalRows = 0;
var errors = new List<string>();
using (var workbook = new XLWorkbook(filePath))
{
var worksheet = workbook.Worksheet(1);
var rows = worksheet.RangeUsed().RowsUsed().Skip(1);
totalRows = rows.Count();
foreach (var row in rows)
{
var orderNumber = row.Cell(1).GetString().Trim();
var expressCompanyName = row.Cell(11).GetString().Trim();
var trackingNumber = row.Cell(12).GetString().Trim();
if (string.IsNullOrEmpty(orderNumber))
continue;
if (string.IsNullOrEmpty(trackingNumber))
{
errors.Add($"订单 {orderNumber}: 缺少快递单号");
continue;
}
var order = await _orderService.GetOrderByNumberAsync(orderNumber);
if (order == null)
{
errors.Add($"订单 {orderNumber}: 未找到");
continue;
}
if (order.Status != 1)
{
errors.Add($"订单 {orderNumber}: 状态不是待发货");
continue;
}
var expressId = ExpressCompanies.GetIdByName(expressCompanyName);
if (expressId == 0)
expressId = -1;
ordersToShip.Add(new ShipOrderRequest
{
OrderId = order.Id,
OrderNumber = orderNumber,
ExpressCompanyId = expressId,
TrackingNumber = trackingNumber
});
}
}
var shipResult = await _shipService.BatchShipOrdersAsync(ordersToShip);
return new ImportShipResult
{
TotalRows = totalRows,
ValidOrders = ordersToShip.Count,
SuccessCount = shipResult.SuccessCount,
FailedCount = shipResult.FailedCount,
Errors = errors.Concat(
shipResult.Results
.Where(r => !r.Success)
.Select(r => $"订单 {r.OrderNumber}: {r.Error}")
).ToList()
};
}
}
}

View File

@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PackagingMallShipper.Models;
namespace PackagingMallShipper.Services
{
public interface IAuthService
{
Task<LoginResult> LoginAsync(string mobile, string password);
string GetToken();
bool IsLoggedIn { get; }
LocalSession CurrentSession { get; }
void Logout();
}
public interface IOrderService
{
Task<List<Order>> GetOrdersAsync(int? status = null, string keyword = null);
Task<Order> GetOrderByIdAsync(int orderId);
Task<Order> GetOrderByNumberAsync(string orderNumber);
Task<int> GetOrderCountAsync(int? status = null);
}
public interface ISyncService
{
Task<SyncResult> SyncOrdersAsync(SyncMode mode = SyncMode.Incremental);
event Action<int, int> OnSyncProgress;
event Action<string> OnSyncMessage;
}
public interface IShipService
{
Task<ShipResult> ShipOrderAsync(ShipOrderRequest request);
Task<BatchShipResult> BatchShipOrdersAsync(
List<ShipOrderRequest> orders,
int concurrency = 3,
CancellationToken cancellationToken = default);
event Action<int, int> OnShipProgress;
}
public interface IExcelService
{
Task<int> ExportPendingOrdersAsync(string filePath);
Task<ImportShipResult> ImportAndShipAsync(string filePath);
}
}

View File

@@ -0,0 +1,147 @@
using System.Collections.Generic;
using System.Data.SQLite;
using System.Threading.Tasks;
using PackagingMallShipper.Data;
using PackagingMallShipper.Models;
namespace PackagingMallShipper.Services
{
public class OrderService : IOrderService
{
public Task<List<Order>> GetOrdersAsync(int? status = null, string keyword = null)
{
return Task.Run(() =>
{
var orders = new List<Order>();
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var sql = "SELECT * FROM orders_cache WHERE 1=1";
if (status.HasValue && status.Value >= 0)
sql += " AND status = @status";
if (!string.IsNullOrWhiteSpace(keyword))
sql += " AND (order_number LIKE @keyword OR logistics_name LIKE @keyword OR logistics_mobile LIKE @keyword)";
sql += " ORDER BY date_add DESC";
using (var cmd = new SQLiteCommand(sql, conn))
{
if (status.HasValue && status.Value >= 0)
cmd.Parameters.AddWithValue("@status", status.Value);
if (!string.IsNullOrWhiteSpace(keyword))
cmd.Parameters.AddWithValue("@keyword", $"%{keyword}%");
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
orders.Add(MapOrder(reader));
}
}
}
}
return orders;
});
}
public Task<Order> GetOrderByIdAsync(int orderId)
{
return Task.Run(() =>
{
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var sql = "SELECT * FROM orders_cache WHERE id = @id";
using (var cmd = new SQLiteCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@id", orderId);
using (var reader = cmd.ExecuteReader())
{
if (reader.Read())
return MapOrder(reader);
}
}
}
return null;
});
}
public Task<Order> GetOrderByNumberAsync(string orderNumber)
{
return Task.Run(() =>
{
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var sql = "SELECT * FROM orders_cache WHERE order_number = @orderNumber";
using (var cmd = new SQLiteCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@orderNumber", orderNumber);
using (var reader = cmd.ExecuteReader())
{
if (reader.Read())
return MapOrder(reader);
}
}
}
return null;
});
}
public Task<int> GetOrderCountAsync(int? status = null)
{
return Task.Run(() =>
{
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var sql = "SELECT COUNT(*) FROM orders_cache";
if (status.HasValue && status.Value >= 0)
sql += " WHERE status = @status";
using (var cmd = new SQLiteCommand(sql, conn))
{
if (status.HasValue && status.Value >= 0)
cmd.Parameters.AddWithValue("@status", status.Value);
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
});
}
private Order MapOrder(SQLiteDataReader reader)
{
return new Order
{
Id = reader.GetInt32(reader.GetOrdinal("id")),
OrderNumber = reader.GetStringSafe("order_number"),
Status = reader.GetInt32Safe("status"),
Amount = reader.GetDecimalSafe("amount"),
AmountReal = reader.GetDecimalSafe("amount_real"),
Uid = reader.GetInt32Safe("uid"),
UserMobile = reader.GetStringSafe("user_mobile"),
LogisticsName = reader.GetStringSafe("logistics_name"),
LogisticsMobile = reader.GetStringSafe("logistics_mobile"),
LogisticsProvince = reader.GetStringSafe("logistics_province"),
LogisticsCity = reader.GetStringSafe("logistics_city"),
LogisticsDistrict = reader.GetStringSafe("logistics_district"),
LogisticsAddress = reader.GetStringSafe("logistics_address"),
GoodsJson = reader.GetStringSafe("goods_json"),
ExpressCompanyId = reader.GetInt32Nullable("express_company_id"),
ExpressCompanyName = reader.GetStringSafe("express_company_name"),
TrackingNumber = reader.GetStringSafe("tracking_number"),
DateShip = reader.GetDateTimeSafe("date_ship"),
SyncStatus = reader.GetStringSafe("sync_status"),
LocalUpdatedAt = reader.GetDateTimeSafe("local_updated_at"),
DateAdd = reader.GetDateTimeSafe("date_add"),
DatePay = reader.GetDateTimeSafe("date_pay"),
DateUpdate = reader.GetDateTimeSafe("date_update"),
SyncedAt = reader.GetDateTimeSafe("synced_at")
};
}
}
}

View File

@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Data.SQLite;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using PackagingMallShipper.Data;
using PackagingMallShipper.Helpers;
using PackagingMallShipper.Models;
namespace PackagingMallShipper.Services
{
public class ShipService : IShipService
{
private readonly IAuthService _authService;
private readonly HttpClient _httpClient;
public event Action<int, int> OnShipProgress;
public ShipService(IAuthService authService)
{
_authService = authService;
_httpClient = new HttpClient();
_httpClient.Timeout = TimeSpan.FromSeconds(30);
}
public async Task<ShipResult> ShipOrderAsync(ShipOrderRequest request)
{
var token = _authService.GetToken();
if (string.IsNullOrEmpty(token))
throw new UnauthorizedAccessException("未登录");
UpdateLocalOrderStatus(request.OrderId, "shipping");
try
{
var url = AppConfig.GetApiUrl($"/order/delivery") +
$"?orderId={request.OrderId}" +
$"&expressType={request.ExpressCompanyId}" +
$"&shipperCode={Uri.EscapeDataString(request.TrackingNumber)}";
_httpClient.DefaultRequestHeaders.Clear();
_httpClient.DefaultRequestHeaders.Add("X-Token", token);
var response = await _httpClient.PostAsync(url, null);
var json = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<ApiResponse<object>>(json);
if (result?.Code != 0)
throw new Exception(result?.Msg ?? "发货失败");
UpdateLocalOrderAfterShip(request);
return new ShipResult { Success = true };
}
catch (Exception ex)
{
UpdateLocalOrderStatus(request.OrderId, "failed", ex.Message);
throw;
}
}
public async Task<BatchShipResult> BatchShipOrdersAsync(
List<ShipOrderRequest> orders,
int concurrency = 3,
CancellationToken cancellationToken = default)
{
var result = new BatchShipResult
{
Total = orders.Count,
Results = new List<ShipOrderResult>()
};
int completed = 0;
using (var semaphore = new SemaphoreSlim(concurrency))
{
var tasks = orders.Select(async order =>
{
await semaphore.WaitAsync(cancellationToken);
try
{
cancellationToken.ThrowIfCancellationRequested();
await ShipOrderAsync(order);
return new ShipOrderResult
{
OrderId = order.OrderId,
OrderNumber = order.OrderNumber,
Success = true
};
}
catch (OperationCanceledException)
{
return new ShipOrderResult
{
OrderId = order.OrderId,
OrderNumber = order.OrderNumber,
Success = false,
Error = "已取消"
};
}
catch (Exception ex)
{
return new ShipOrderResult
{
OrderId = order.OrderId,
OrderNumber = order.OrderNumber,
Success = false,
Error = ex.Message
};
}
finally
{
semaphore.Release();
Interlocked.Increment(ref completed);
OnShipProgress?.Invoke(completed, orders.Count);
}
});
var shipResults = await Task.WhenAll(tasks);
result.Results.AddRange(shipResults);
}
result.SuccessCount = result.Results.Count(r => r.Success);
result.FailedCount = result.Results.Count(r => !r.Success);
return result;
}
private void UpdateLocalOrderStatus(int orderId, string status, string errorMsg = null)
{
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var sql = @"UPDATE orders_cache
SET sync_status = @status, local_updated_at = @now
WHERE id = @id";
using (var cmd = new SQLiteCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@status", status);
cmd.Parameters.AddWithValue("@now", DateTime.Now);
cmd.Parameters.AddWithValue("@id", orderId);
cmd.ExecuteNonQuery();
}
}
}
private void UpdateLocalOrderAfterShip(ShipOrderRequest request)
{
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var sql = @"UPDATE orders_cache SET
status = 2,
sync_status = 'synced',
express_company_id = @expressId,
express_company_name = @expressName,
tracking_number = @trackingNumber,
date_ship = @dateShip,
local_updated_at = @now
WHERE id = @id";
using (var cmd = new SQLiteCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@expressId", request.ExpressCompanyId);
cmd.Parameters.AddWithValue("@expressName",
ExpressCompanies.GetName(request.ExpressCompanyId));
cmd.Parameters.AddWithValue("@trackingNumber", request.TrackingNumber);
cmd.Parameters.AddWithValue("@dateShip", DateTime.Now);
cmd.Parameters.AddWithValue("@now", DateTime.Now);
cmd.Parameters.AddWithValue("@id", request.OrderId);
cmd.ExecuteNonQuery();
}
}
}
}
}

View File

@@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using System.Data.SQLite;
using System.Net.Http;
using System.Threading.Tasks;
using Newtonsoft.Json;
using PackagingMallShipper.Data;
using PackagingMallShipper.Helpers;
using PackagingMallShipper.Models;
namespace PackagingMallShipper.Services
{
public class SyncService : ISyncService
{
private readonly IAuthService _authService;
private readonly HttpClient _httpClient;
public event Action<int, int> OnSyncProgress;
public event Action<string> OnSyncMessage;
public SyncService(IAuthService authService)
{
_authService = authService;
_httpClient = new HttpClient();
_httpClient.Timeout = TimeSpan.FromSeconds(60);
}
public async Task<SyncResult> SyncOrdersAsync(SyncMode mode = SyncMode.Incremental)
{
var token = _authService.GetToken();
if (string.IsNullOrEmpty(token))
throw new UnauthorizedAccessException("未登录,请先登录");
var result = new SyncResult();
var syncLog = new SyncLog
{
SyncType = "manual",
SyncMode = mode.ToString().ToLower(),
SyncStart = DateTime.Now,
Status = "running"
};
try
{
DateTime? lastSyncTime = null;
if (mode == SyncMode.Incremental)
{
lastSyncTime = GetLastSuccessSyncTime();
}
int page = 1;
int pageSize = AppConfig.SyncPageSize;
bool hasMore = true;
int totalPages = 1;
while (hasMore)
{
OnSyncMessage?.Invoke($"正在同步第 {page}/{totalPages} 页...");
var url = AppConfig.GetApiUrl($"/order/list?page={page}&pageSize={pageSize}");
if (lastSyncTime.HasValue)
url += $"&dateUpdateBegin={lastSyncTime:yyyy-MM-dd HH:mm:ss}";
_httpClient.DefaultRequestHeaders.Clear();
_httpClient.DefaultRequestHeaders.Add("X-Token", token);
var response = await _httpClient.GetAsync(url);
var json = await response.Content.ReadAsStringAsync();
var data = JsonConvert.DeserializeObject<ApiResponse<OrderListData>>(json);
if (data?.Code != 0)
throw new Exception(data?.Msg ?? "获取订单失败");
var orders = data.Data?.OrderList ?? new List<OrderDto>();
totalPages = data.Data?.TotalPage ?? 1;
if (orders.Count == 0) break;
foreach (var order in orders)
{
var isNew = await SaveOrderAsync(order);
if (isNew) result.NewCount++;
else result.UpdatedCount++;
}
result.TotalCount += orders.Count;
OnSyncProgress?.Invoke(page, totalPages);
if (page >= totalPages) hasMore = false;
else page++;
}
syncLog.Status = "success";
syncLog.SyncEnd = DateTime.Now;
syncLog.TotalCount = result.TotalCount;
syncLog.NewCount = result.NewCount;
syncLog.UpdatedCount = result.UpdatedCount;
OnSyncMessage?.Invoke($"同步完成!新增 {result.NewCount},更新 {result.UpdatedCount}");
}
catch (Exception ex)
{
syncLog.Status = "failed";
syncLog.ErrorMessage = ex.Message;
throw;
}
finally
{
SaveSyncLog(syncLog);
}
return result;
}
private async Task<bool> SaveOrderAsync(OrderDto dto)
{
return await Task.Run(() =>
{
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var checkSql = "SELECT id FROM orders_cache WHERE order_number = @orderNumber";
bool exists = false;
using (var cmd = new SQLiteCommand(checkSql, conn))
{
cmd.Parameters.AddWithValue("@orderNumber", dto.OrderNumber);
exists = cmd.ExecuteScalar() != null;
}
var sql = exists
? @"UPDATE orders_cache SET
status = @status, amount = @amount, amount_real = @amountReal,
logistics_name = @logisticsName, logistics_mobile = @logisticsMobile,
logistics_province = @province, logistics_city = @city,
logistics_district = @district, logistics_address = @address,
goods_json = @goodsJson, express_company_id = @expressId,
tracking_number = @trackingNumber, date_ship = @dateShip,
date_update = @dateUpdate, synced_at = @syncedAt
WHERE order_number = @orderNumber"
: @"INSERT INTO orders_cache
(id, order_number, status, amount, amount_real, uid, user_mobile,
logistics_name, logistics_mobile, logistics_province, logistics_city,
logistics_district, logistics_address, goods_json, express_company_id,
tracking_number, date_ship, date_add, date_pay, date_update, synced_at)
VALUES
(@id, @orderNumber, @status, @amount, @amountReal, @uid, @userMobile,
@logisticsName, @logisticsMobile, @province, @city, @district, @address,
@goodsJson, @expressId, @trackingNumber, @dateShip, @dateAdd, @datePay,
@dateUpdate, @syncedAt)";
using (var cmd = new SQLiteCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@id", dto.Id);
cmd.Parameters.AddWithValue("@orderNumber", dto.OrderNumber);
cmd.Parameters.AddWithValue("@status", dto.Status);
cmd.Parameters.AddWithValue("@amount", dto.Amount);
cmd.Parameters.AddWithValue("@amountReal", dto.AmountReal);
cmd.Parameters.AddWithValue("@uid", dto.Uid);
cmd.Parameters.AddWithValue("@userMobile", dto.UserMobile ?? "");
cmd.Parameters.AddWithValue("@logisticsName", dto.LogisticsName ?? "");
cmd.Parameters.AddWithValue("@logisticsMobile", dto.LogisticsMobile ?? "");
cmd.Parameters.AddWithValue("@province", dto.LogisticsProvince ?? "");
cmd.Parameters.AddWithValue("@city", dto.LogisticsCity ?? "");
cmd.Parameters.AddWithValue("@district", dto.LogisticsDistrict ?? "");
cmd.Parameters.AddWithValue("@address", dto.LogisticsAddress ?? "");
cmd.Parameters.AddWithValue("@goodsJson", JsonConvert.SerializeObject(dto.Goods ?? new List<GoodsItem>()));
cmd.Parameters.AddWithValue("@expressId", (object)dto.ShipperId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@trackingNumber", dto.ShipperCode ?? "");
cmd.Parameters.AddWithValue("@dateShip", (object)dto.DateShip ?? DBNull.Value);
cmd.Parameters.AddWithValue("@dateAdd", dto.DateAdd);
cmd.Parameters.AddWithValue("@datePay", (object)dto.DatePay ?? DBNull.Value);
cmd.Parameters.AddWithValue("@dateUpdate", dto.DateUpdate);
cmd.Parameters.AddWithValue("@syncedAt", DateTime.Now);
cmd.ExecuteNonQuery();
}
return !exists;
}
});
}
private DateTime? GetLastSuccessSyncTime()
{
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var sql = @"SELECT sync_end FROM sync_logs
WHERE status = 'success'
ORDER BY sync_end DESC LIMIT 1";
using (var cmd = new SQLiteCommand(sql, conn))
{
var result = cmd.ExecuteScalar();
return result as DateTime?;
}
}
}
private void SaveSyncLog(SyncLog log)
{
using (var conn = SqliteHelper.GetConnection())
{
conn.Open();
var sql = @"INSERT INTO sync_logs
(sync_type, sync_mode, sync_start, sync_end, total_count,
new_count, updated_count, failed_count, status, error_message)
VALUES
(@type, @mode, @start, @end, @total, @new, @updated, @failed, @status, @error)";
using (var cmd = new SQLiteCommand(sql, conn))
{
cmd.Parameters.AddWithValue("@type", log.SyncType);
cmd.Parameters.AddWithValue("@mode", log.SyncMode);
cmd.Parameters.AddWithValue("@start", log.SyncStart);
cmd.Parameters.AddWithValue("@end", (object)log.SyncEnd ?? DBNull.Value);
cmd.Parameters.AddWithValue("@total", log.TotalCount);
cmd.Parameters.AddWithValue("@new", log.NewCount);
cmd.Parameters.AddWithValue("@updated", log.UpdatedCount);
cmd.Parameters.AddWithValue("@failed", log.FailedCount);
cmd.Parameters.AddWithValue("@status", log.Status);
cmd.Parameters.AddWithValue("@error", log.ErrorMessage ?? "");
cmd.ExecuteNonQuery();
}
}
}
}
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PackagingMallShipper.Services;
namespace PackagingMallShipper.ViewModels
{
public partial class LoginViewModel : ViewModelBase
{
private readonly IAuthService _authService;
[ObservableProperty]
private string _mobile = "";
[ObservableProperty]
private string _password = "";
[ObservableProperty]
private string _errorMessage = "";
[ObservableProperty]
private bool _rememberMe = true;
public event Action OnLoginSuccess;
public LoginViewModel(IAuthService authService)
{
_authService = authService;
LoadSavedCredentials();
}
[RelayCommand]
private async Task LoginAsync()
{
if (string.IsNullOrWhiteSpace(Mobile))
{
ErrorMessage = "请输入手机号";
return;
}
if (string.IsNullOrWhiteSpace(Password))
{
ErrorMessage = "请输入密码";
return;
}
IsBusy = true;
ErrorMessage = "";
try
{
var result = await _authService.LoginAsync(Mobile, Password);
if (result.Success)
{
if (RememberMe)
{
SaveCredentials();
}
OnLoginSuccess?.Invoke();
}
else
{
ErrorMessage = result.Message ?? "登录失败";
}
}
catch (Exception ex)
{
ErrorMessage = $"登录异常: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private void LoadSavedCredentials()
{
try
{
var mobile = Properties.Settings.Default.SavedMobile;
if (!string.IsNullOrEmpty(mobile))
{
Mobile = mobile;
RememberMe = true;
}
}
catch
{
// Ignore
}
}
private void SaveCredentials()
{
try
{
Properties.Settings.Default.SavedMobile = Mobile;
Properties.Settings.Default.Save();
}
catch
{
// Ignore
}
}
}
}

View File

@@ -0,0 +1,38 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PackagingMallShipper.Services;
namespace PackagingMallShipper.ViewModels
{
public partial class MainViewModel : ViewModelBase
{
private readonly IAuthService _authService;
[ObservableProperty]
private string _userName = "";
[ObservableProperty]
private OrderListViewModel _orderListViewModel;
public event Action OnLogout;
public MainViewModel(IAuthService authService, OrderListViewModel orderListViewModel)
{
_authService = authService;
_orderListViewModel = orderListViewModel;
if (_authService.CurrentSession != null)
{
UserName = _authService.CurrentSession.Nickname ?? _authService.CurrentSession.Mobile;
}
}
[RelayCommand]
private void Logout()
{
_authService.Logout();
OnLogout?.Invoke();
}
}
}

View File

@@ -0,0 +1,288 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using PackagingMallShipper.Helpers;
using PackagingMallShipper.Models;
using PackagingMallShipper.Services;
namespace PackagingMallShipper.ViewModels
{
public partial class OrderListViewModel : ViewModelBase
{
private readonly IOrderService _orderService;
private readonly ISyncService _syncService;
private readonly IShipService _shipService;
private readonly IExcelService _excelService;
[ObservableProperty]
private ObservableCollection<Order> _orders = new ObservableCollection<Order>();
[ObservableProperty]
private Order _selectedOrder;
[ObservableProperty]
private int _selectedStatusIndex = 0;
[ObservableProperty]
private string _searchText = "";
[ObservableProperty]
private int _totalCount;
[ObservableProperty]
private int _pendingCount;
[ObservableProperty]
private string _syncProgress = "";
public OrderListViewModel(
IOrderService orderService,
ISyncService syncService,
IShipService shipService,
IExcelService excelService)
{
_orderService = orderService;
_syncService = syncService;
_shipService = shipService;
_excelService = excelService;
_syncService.OnSyncProgress += (current, total) =>
{
SyncProgress = $"同步中 {current}/{total}";
};
_syncService.OnSyncMessage += (msg) =>
{
StatusMessage = msg;
};
_shipService.OnShipProgress += (current, total) =>
{
StatusMessage = $"发货中 {current}/{total}";
};
}
public async Task InitializeAsync()
{
await RefreshOrdersAsync();
await UpdateCountsAsync();
}
[RelayCommand]
private async Task RefreshOrdersAsync()
{
IsBusy = true;
StatusMessage = "加载中...";
try
{
int? status = SelectedStatusIndex switch
{
0 => 1, // 待发货
1 => 2, // 已发货
2 => null, // 全部
_ => null
};
var orders = await _orderService.GetOrdersAsync(status, SearchText);
Orders.Clear();
foreach (var order in orders)
{
Orders.Add(order);
}
TotalCount = orders.Count;
StatusMessage = $"共 {TotalCount} 条订单";
}
catch (Exception ex)
{
StatusMessage = $"加载失败: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task SyncOrdersAsync()
{
IsBusy = true;
StatusMessage = "正在同步订单...";
try
{
var result = await _syncService.SyncOrdersAsync(SyncMode.Incremental);
await RefreshOrdersAsync();
await UpdateCountsAsync();
StatusMessage = $"同步完成!新增 {result.NewCount},更新 {result.UpdatedCount}";
}
catch (UnauthorizedAccessException)
{
StatusMessage = "登录已过期,请重新登录";
MessageBox.Show("登录已过期,请重新登录", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
}
catch (Exception ex)
{
StatusMessage = $"同步失败: {ex.Message}";
MessageBox.Show($"同步失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
IsBusy = false;
SyncProgress = "";
}
}
[RelayCommand]
private async Task FullSyncAsync()
{
var result = MessageBox.Show("全量同步将重新获取所有订单,可能需要较长时间,确定继续?",
"确认", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result != MessageBoxResult.Yes) return;
IsBusy = true;
StatusMessage = "正在全量同步...";
try
{
var syncResult = await _syncService.SyncOrdersAsync(SyncMode.Full);
await RefreshOrdersAsync();
await UpdateCountsAsync();
StatusMessage = $"全量同步完成!共 {syncResult.TotalCount} 条";
}
catch (Exception ex)
{
StatusMessage = $"同步失败: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task ExportExcelAsync()
{
var dialog = new SaveFileDialog
{
Filter = "Excel文件|*.xlsx",
FileName = $"待发货订单_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx"
};
if (dialog.ShowDialog() != true) return;
IsBusy = true;
StatusMessage = "正在导出...";
try
{
var count = await _excelService.ExportPendingOrdersAsync(dialog.FileName);
StatusMessage = $"导出成功!共 {count} 条订单";
MessageBox.Show($"导出成功!共 {count} 条订单\n\n文件位置{dialog.FileName}",
"导出成功", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
StatusMessage = $"导出失败: {ex.Message}";
MessageBox.Show($"导出失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task ImportAndShipAsync()
{
var dialog = new OpenFileDialog
{
Filter = "Excel文件|*.xlsx;*.xls",
Title = "选择发货单号文件"
};
if (dialog.ShowDialog() != true) return;
IsBusy = true;
StatusMessage = "正在导入并发货...";
try
{
var result = await _excelService.ImportAndShipAsync(dialog.FileName);
await RefreshOrdersAsync();
await UpdateCountsAsync();
var message = $"导入完成!\n" +
$"总行数: {result.TotalRows}\n" +
$"有效订单: {result.ValidOrders}\n" +
$"成功: {result.SuccessCount}\n" +
$"失败: {result.FailedCount}";
if (result.Errors.Any())
{
message += $"\n\n错误详情:\n{string.Join("\n", result.Errors.Take(10))}";
if (result.Errors.Count > 10)
message += $"\n...还有 {result.Errors.Count - 10} 个错误";
}
MessageBox.Show(message, "导入结果", MessageBoxButton.OK,
result.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information);
StatusMessage = $"发货完成: 成功 {result.SuccessCount},失败 {result.FailedCount}";
}
catch (Exception ex)
{
StatusMessage = $"导入失败: {ex.Message}";
MessageBox.Show($"导入失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task BatchShipSelectedAsync()
{
var selectedOrders = Orders.Where(o => o.IsSelected && o.Status == 1).ToList();
if (!selectedOrders.Any())
{
MessageBox.Show("请先选择待发货的订单", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
MessageBox.Show($"批量发货功能需要先填写快递信息请使用「导出Excel → 填写快递单号 → 导入发货」流程",
"提示", MessageBoxButton.OK, MessageBoxImage.Information);
}
private async Task UpdateCountsAsync()
{
try
{
PendingCount = await _orderService.GetOrderCountAsync(1);
}
catch
{
// Ignore
}
}
partial void OnSelectedStatusIndexChanged(int value)
{
_ = RefreshOrdersAsync();
}
partial void OnSearchTextChanged(string value)
{
// Debounce search - simple implementation
}
}
}

View File

@@ -0,0 +1,21 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace PackagingMallShipper.ViewModels
{
public abstract class ViewModelBase : ObservableObject
{
private bool _isBusy;
public bool IsBusy
{
get => _isBusy;
set => SetProperty(ref _isBusy, value);
}
private string _statusMessage;
public string StatusMessage
{
get => _statusMessage;
set => SetProperty(ref _statusMessage, value);
}
}
}

View File

@@ -0,0 +1,60 @@
<Window x:Class="PackagingMallShipper.Views.LoginWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="包装商城发货助手 - 登录"
Height="400" Width="350"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
Background="#F5F5F5">
<Grid>
<Border Background="White" CornerRadius="8" Margin="20" Padding="30">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="2" Opacity="0.1"/>
</Border.Effect>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="📦 包装商城发货助手"
FontSize="20" FontWeight="Bold"
HorizontalAlignment="Center"
Margin="0,0,0,30"/>
<TextBlock Text="手机号" Margin="0,0,0,5" FontSize="13"/>
<TextBox x:Name="MobileTextBox"
Text="{Binding Mobile, UpdateSourceTrigger=PropertyChanged}"
Height="35" FontSize="14" Padding="10,5"
Margin="0,0,0,15"/>
<TextBlock Text="密码" Margin="0,0,0,5" FontSize="13"/>
<PasswordBox x:Name="PasswordBox"
Height="35" FontSize="14" Padding="10,5"
Margin="0,0,0,10"
PasswordChanged="PasswordBox_PasswordChanged"/>
<CheckBox Content="记住手机号"
IsChecked="{Binding RememberMe}"
Margin="0,0,0,20"/>
<TextBlock Text="{Binding ErrorMessage}"
Foreground="Red"
TextWrapping="Wrap"
Margin="0,0,0,10"
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibility}}"/>
<Button Content="登 录"
Command="{Binding LoginCommand}"
Height="40" FontSize="15"
Background="#1890FF" Foreground="White"
BorderThickness="0"
Cursor="Hand"
IsEnabled="{Binding IsBusy, Converter={StaticResource InverseBool}}"/>
<ProgressBar IsIndeterminate="True" Height="3" Margin="0,10,0,0"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}"/>
</StackPanel>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,63 @@
using System.Windows;
using System.Windows.Controls;
using PackagingMallShipper.Services;
using PackagingMallShipper.ViewModels;
namespace PackagingMallShipper.Views
{
public partial class LoginWindow : Window
{
private readonly LoginViewModel _viewModel;
private readonly IAuthService _authService;
public LoginWindow()
{
InitializeComponent();
_authService = new AuthService();
_viewModel = new LoginViewModel(_authService);
DataContext = _viewModel;
_viewModel.OnLoginSuccess += OnLoginSuccess;
if (_authService.IsLoggedIn)
{
OpenMainWindow();
}
}
private void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e)
{
if (sender is PasswordBox pb)
{
_viewModel.Password = pb.Password;
}
}
private void OnLoginSuccess()
{
OpenMainWindow();
}
private void OpenMainWindow()
{
var orderService = new OrderService();
var syncService = new SyncService(_authService);
var shipService = new ShipService(_authService);
var excelService = new ExcelService(orderService, shipService);
var orderListViewModel = new OrderListViewModel(orderService, syncService, shipService, excelService);
var mainViewModel = new MainViewModel(_authService, orderListViewModel);
var mainWindow = new MainWindow(mainViewModel);
mainViewModel.OnLogout += () =>
{
var loginWindow = new LoginWindow();
loginWindow.Show();
mainWindow.Close();
};
mainWindow.Show();
this.Close();
}
}
}

View File

@@ -0,0 +1,57 @@
<Window x:Class="PackagingMallShipper.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:PackagingMallShipper.Views"
mc:Ignorable="d"
Title="包装商城发货助手"
Height="700" Width="1100"
WindowStartupLocation="CenterScreen"
MinHeight="500" MinWidth="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition Height="*"/>
<RowDefinition Height="25"/>
</Grid.RowDefinitions>
<!-- 顶部工具栏 -->
<Border Grid.Row="0" Background="#1890FF">
<Grid Margin="15,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="📦 包装商城发货助手"
Foreground="White" FontSize="16" FontWeight="Bold"
VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Text="{Binding UserName}"
Foreground="White" FontSize="13"
VerticalAlignment="Center" Margin="0,0,15,0"/>
<Button Content="退出登录"
Command="{Binding LogoutCommand}"
Background="Transparent" Foreground="White"
BorderThickness="1" BorderBrush="White"
Padding="10,5" Cursor="Hand"/>
</StackPanel>
</Grid>
</Border>
<!-- 主内容区 -->
<local:OrderListView Grid.Row="1"
DataContext="{Binding OrderListViewModel}"/>
<!-- 状态栏 -->
<Border Grid.Row="2" Background="#F0F0F0">
<Grid Margin="10,0">
<TextBlock Text="{Binding OrderListViewModel.StatusMessage}"
VerticalAlignment="Center" FontSize="12"/>
<TextBlock Text="{Binding OrderListViewModel.SyncProgress}"
HorizontalAlignment="Right"
VerticalAlignment="Center" FontSize="12"/>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,19 @@
using System.Windows;
using PackagingMallShipper.ViewModels;
namespace PackagingMallShipper.Views
{
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
Loaded += async (s, e) =>
{
await viewModel.OrderListViewModel.InitializeAsync();
};
}
}
}

View File

@@ -0,0 +1,149 @@
<UserControl x:Class="PackagingMallShipper.Views.OrderListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Background="White">
<Grid Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 统计信息 -->
<Border Grid.Row="0" Background="#F5F7FA" CornerRadius="4" Padding="15" Margin="0,0,0,15">
<StackPanel Orientation="Horizontal">
<StackPanel Margin="0,0,40,0">
<TextBlock Text="待发货订单" FontSize="12" Foreground="#666"/>
<TextBlock Text="{Binding PendingCount}" FontSize="24" FontWeight="Bold" Foreground="#1890FF"/>
</StackPanel>
<StackPanel>
<TextBlock Text="当前列表" FontSize="12" Foreground="#666"/>
<TextBlock Text="{Binding TotalCount}" FontSize="24" FontWeight="Bold" Foreground="#52C41A"/>
</StackPanel>
</StackPanel>
</Border>
<!-- 工具栏 -->
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,0,0,10">
<ComboBox Width="100" SelectedIndex="{Binding SelectedStatusIndex}" Height="30">
<ComboBoxItem Content="待发货"/>
<ComboBoxItem Content="已发货"/>
<ComboBoxItem Content="全部"/>
</ComboBox>
<TextBox Width="200" Margin="10,0"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
VerticalContentAlignment="Center" Height="30"
Padding="5,0">
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Triggers>
<Trigger Property="Text" Value="">
<Setter Property="Background">
<Setter.Value>
<VisualBrush Stretch="None" AlignmentX="Left">
<VisualBrush.Visual>
<TextBlock Text="搜索订单号/收件人/电话" Foreground="Gray" Margin="5,0"/>
</VisualBrush.Visual>
</VisualBrush>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<Button Content="🔍 搜索" Command="{Binding RefreshOrdersCommand}"
Width="70" Height="30" Margin="0,0,10,0"/>
<Separator Width="1" Background="#DDD" Margin="10,5"/>
<Button Content="🔄 同步订单" Command="{Binding SyncOrdersCommand}"
Width="90" Height="30" Margin="0,0,5,0"
IsEnabled="{Binding IsBusy, Converter={StaticResource InverseBool}}"/>
<Button Content="📥 全量同步" Command="{Binding FullSyncCommand}"
Width="90" Height="30" Margin="0,0,10,0"
IsEnabled="{Binding IsBusy, Converter={StaticResource InverseBool}}"/>
<Separator Width="1" Background="#DDD" Margin="10,5"/>
<Button Content="📤 导出Excel" Command="{Binding ExportExcelCommand}"
Width="90" Height="30" Margin="0,0,5,0"
IsEnabled="{Binding IsBusy, Converter={StaticResource InverseBool}}"/>
<Button Content="📥 导入发货" Command="{Binding ImportAndShipCommand}"
Width="90" Height="30"
Background="#52C41A" Foreground="White" BorderThickness="0"
IsEnabled="{Binding IsBusy, Converter={StaticResource InverseBool}}"/>
</StackPanel>
<!-- 订单列表 -->
<DataGrid Grid.Row="2"
ItemsSource="{Binding Orders}"
SelectedItem="{Binding SelectedOrder}"
AutoGenerateColumns="False"
IsReadOnly="True"
SelectionMode="Extended"
CanUserAddRows="False"
CanUserDeleteRows="False"
GridLinesVisibility="Horizontal"
HorizontalGridLinesBrush="#EEE"
RowHeight="40"
HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridCheckBoxColumn Binding="{Binding IsSelected, UpdateSourceTrigger=PropertyChanged}"
Width="40" Header=""/>
<DataGridTextColumn Header="订单号" Binding="{Binding OrderNumber}" Width="150"/>
<DataGridTextColumn Header="状态" Binding="{Binding StatusText}" Width="70">
<DataGridTextColumn.ElementStyle>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="1">
<Setter Property="Foreground" Value="#FA8C16"/>
<Setter Property="FontWeight" Value="Bold"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="2">
<Setter Property="Foreground" Value="#52C41A"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGridTextColumn.ElementStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="下单时间"
Binding="{Binding DateAdd, StringFormat=MM-dd HH:mm}"
Width="100"/>
<DataGridTextColumn Header="收件人" Binding="{Binding LogisticsName}" Width="80"/>
<DataGridTextColumn Header="电话" Binding="{Binding LogisticsMobile}" Width="110"/>
<DataGridTextColumn Header="地址" Binding="{Binding FullAddress}" Width="*"/>
<DataGridTextColumn Header="金额"
Binding="{Binding AmountReal, StringFormat=¥{0:F2}}"
Width="70"/>
<DataGridTextColumn Header="快递单号" Binding="{Binding TrackingNumber}" Width="150"/>
</DataGrid.Columns>
</DataGrid>
<!-- 加载遮罩 -->
<Border Grid.Row="2" Background="#80FFFFFF"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}">
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="⏳" FontSize="30" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding StatusMessage}" FontSize="14" Margin="0,10,0,0"/>
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

@@ -0,0 +1,12 @@
using System.Windows.Controls;
namespace PackagingMallShipper.Views
{
public partial class OrderListView : UserControl
{
public OrderListView()
{
InitializeComponent();
}
}
}

View File

@@ -0,0 +1,49 @@
<Window x:Class="PackagingMallShipper.Views.ShippingDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="填写发货信息"
Height="280" Width="400"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize"
ShowInTaskbar="False">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 订单信息 -->
<StackPanel Grid.Row="0" Margin="0,0,0,15">
<TextBlock Text="订单号:" FontWeight="Bold"/>
<TextBlock x:Name="OrderNumberText" FontSize="14" Margin="0,5,0,0"/>
</StackPanel>
<!-- 快递公司 -->
<StackPanel Grid.Row="1" Margin="0,0,0,15">
<TextBlock Text="快递公司" Margin="0,0,0,5"/>
<ComboBox x:Name="ExpressComboBox" Height="30"
DisplayMemberPath="Name"
SelectedValuePath="Id"/>
</StackPanel>
<!-- 快递单号 -->
<StackPanel Grid.Row="2" Margin="0,0,0,15">
<TextBlock Text="快递单号" Margin="0,0,0,5"/>
<TextBox x:Name="TrackingNumberTextBox" Height="30"
VerticalContentAlignment="Center" Padding="5,0"/>
</StackPanel>
<!-- 按钮 -->
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="取消" Width="80" Height="30" Margin="0,0,10,0"
Click="CancelButton_Click"/>
<Button Content="确认发货" Width="100" Height="30"
Background="#1890FF" Foreground="White" BorderThickness="0"
Click="ConfirmButton_Click"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,50 @@
using System.Windows;
using PackagingMallShipper.Helpers;
using PackagingMallShipper.Models;
namespace PackagingMallShipper.Views
{
public partial class ShippingDialog : Window
{
public int SelectedExpressId { get; private set; }
public string TrackingNumber { get; private set; }
public ShippingDialog(Order order)
{
InitializeComponent();
OrderNumberText.Text = order.OrderNumber;
ExpressComboBox.ItemsSource = ExpressCompanies.All;
ExpressComboBox.SelectedIndex = 0;
}
private void ConfirmButton_Click(object sender, RoutedEventArgs e)
{
var trackingNumber = TrackingNumberTextBox.Text.Trim();
if (string.IsNullOrEmpty(trackingNumber))
{
MessageBox.Show("请输入快递单号", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (ExpressComboBox.SelectedItem is ExpressCompany express)
{
SelectedExpressId = express.Id;
}
else
{
SelectedExpressId = -1;
}
TrackingNumber = trackingNumber;
DialogResult = true;
Close();
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}
}

File diff suppressed because it is too large Load Diff