diff --git a/PackagingMallShipper.sln b/PackagingMallShipper.sln new file mode 100644 index 0000000..509d5d1 --- /dev/null +++ b/PackagingMallShipper.sln @@ -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 diff --git a/PackagingMallShipper/App.config b/PackagingMallShipper/App.config new file mode 100644 index 0000000..aa19cf8 --- /dev/null +++ b/PackagingMallShipper/App.config @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/PackagingMallShipper/App.xaml b/PackagingMallShipper/App.xaml new file mode 100644 index 0000000..c740542 --- /dev/null +++ b/PackagingMallShipper/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/PackagingMallShipper/App.xaml.cs b/PackagingMallShipper/App.xaml.cs new file mode 100644 index 0000000..737d266 --- /dev/null +++ b/PackagingMallShipper/App.xaml.cs @@ -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); + } + } + } +} diff --git a/PackagingMallShipper/Converters/BoolToVisibilityConverter.cs b/PackagingMallShipper/Converters/BoolToVisibilityConverter.cs new file mode 100644 index 0000000..6088b71 --- /dev/null +++ b/PackagingMallShipper/Converters/BoolToVisibilityConverter.cs @@ -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(); + } + } +} diff --git a/PackagingMallShipper/Converters/StatusToColorConverter.cs b/PackagingMallShipper/Converters/StatusToColorConverter.cs new file mode 100644 index 0000000..9d56fe4 --- /dev/null +++ b/PackagingMallShipper/Converters/StatusToColorConverter.cs @@ -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(); + } + } +} diff --git a/PackagingMallShipper/Converters/StatusToTextConverter.cs b/PackagingMallShipper/Converters/StatusToTextConverter.cs new file mode 100644 index 0000000..d3570f7 --- /dev/null +++ b/PackagingMallShipper/Converters/StatusToTextConverter.cs @@ -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(); + } + } +} diff --git a/PackagingMallShipper/Data/Resources/schema.sql b/PackagingMallShipper/Data/Resources/schema.sql new file mode 100644 index 0000000..d0d9910 --- /dev/null +++ b/PackagingMallShipper/Data/Resources/schema.sql @@ -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); diff --git a/PackagingMallShipper/Data/SqliteHelper.cs b/PackagingMallShipper/Data/SqliteHelper.cs new file mode 100644 index 0000000..3f06142 --- /dev/null +++ b/PackagingMallShipper/Data/SqliteHelper.cs @@ -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(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); + } + } +} diff --git a/PackagingMallShipper/Helpers/AppConfig.cs b/PackagingMallShipper/Helpers/AppConfig.cs new file mode 100644 index 0000000..1162cd7 --- /dev/null +++ b/PackagingMallShipper/Helpers/AppConfig.cs @@ -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}"; + } + } +} diff --git a/PackagingMallShipper/Helpers/ExpressCompanies.cs b/PackagingMallShipper/Helpers/ExpressCompanies.cs new file mode 100644 index 0000000..ffbf82e --- /dev/null +++ b/PackagingMallShipper/Helpers/ExpressCompanies.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Linq; + +namespace PackagingMallShipper.Helpers +{ + public static class ExpressCompanies + { + public static readonly List All = new List + { + 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; + } +} diff --git a/PackagingMallShipper/Helpers/ResourceHelper.cs b/PackagingMallShipper/Helpers/ResourceHelper.cs new file mode 100644 index 0000000..0aa7a20 --- /dev/null +++ b/PackagingMallShipper/Helpers/ResourceHelper.cs @@ -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(); + } + } + } + } +} diff --git a/PackagingMallShipper/Models/ApiResponses.cs b/PackagingMallShipper/Models/ApiResponses.cs new file mode 100644 index 0000000..ffa59ab --- /dev/null +++ b/PackagingMallShipper/Models/ApiResponses.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace PackagingMallShipper.Models +{ + public class ApiResponse + { + [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 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 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; } + } +} diff --git a/PackagingMallShipper/Models/LocalSession.cs b/PackagingMallShipper/Models/LocalSession.cs new file mode 100644 index 0000000..85d3a2b --- /dev/null +++ b/PackagingMallShipper/Models/LocalSession.cs @@ -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; } + } +} diff --git a/PackagingMallShipper/Models/Order.cs b/PackagingMallShipper/Models/Order.cs new file mode 100644 index 0000000..155ce31 --- /dev/null +++ b/PackagingMallShipper/Models/Order.cs @@ -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>(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 Map = new Dictionary + { + { -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 + } +} diff --git a/PackagingMallShipper/Models/ShipModels.cs b/PackagingMallShipper/Models/ShipModels.cs new file mode 100644 index 0000000..14d74f6 --- /dev/null +++ b/PackagingMallShipper/Models/ShipModels.cs @@ -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 Results { get; set; } = new List(); + } + + 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 Errors { get; set; } = new List(); + } +} diff --git a/PackagingMallShipper/Models/SyncLog.cs b/PackagingMallShipper/Models/SyncLog.cs new file mode 100644 index 0000000..79866de --- /dev/null +++ b/PackagingMallShipper/Models/SyncLog.cs @@ -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 + } +} diff --git a/PackagingMallShipper/PackagingMallShipper.csproj b/PackagingMallShipper/PackagingMallShipper.csproj new file mode 100644 index 0000000..d70aed1 --- /dev/null +++ b/PackagingMallShipper/PackagingMallShipper.csproj @@ -0,0 +1,139 @@ + + + + + Debug + AnyCPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + WinExe + PackagingMallShipper + PackagingMallShipper + v4.8 + 512 + {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} + 4 + true + true + 7.3 + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + Resources\Icons\app.ico + + + + + + + + + + + + 4.0 + + + + + + + + MSBuild:Compile + Designer + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + + + App.xaml + Code + + + + + + + + + + + + + + + + + + + + + + + + + + LoginWindow.xaml + + + MainWindow.xaml + + + OrderListView.xaml + + + ShippingDialog.xaml + + + + + + + + + + + + + + + + + + + + diff --git a/PackagingMallShipper/Properties/AssemblyInfo.cs b/PackagingMallShipper/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..73f9bad --- /dev/null +++ b/PackagingMallShipper/Properties/AssemblyInfo.cs @@ -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")] diff --git a/PackagingMallShipper/Properties/Settings.Designer.cs b/PackagingMallShipper/Properties/Settings.Designer.cs new file mode 100644 index 0000000..0e0bc93 --- /dev/null +++ b/PackagingMallShipper/Properties/Settings.Designer.cs @@ -0,0 +1,38 @@ +//------------------------------------------------------------------------------ +// +// 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. +// +//------------------------------------------------------------------------------ + +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; + } + } + } +} diff --git a/PackagingMallShipper/Properties/Settings.settings b/PackagingMallShipper/Properties/Settings.settings new file mode 100644 index 0000000..933663f --- /dev/null +++ b/PackagingMallShipper/Properties/Settings.settings @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/PackagingMallShipper/Resources/Styles.xaml b/PackagingMallShipper/Resources/Styles.xaml new file mode 100644 index 0000000..ac72433 --- /dev/null +++ b/PackagingMallShipper/Resources/Styles.xaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PackagingMallShipper/Services/AuthService.cs b/PackagingMallShipper/Services/AuthService.cs new file mode 100644 index 0000000..dbc5b96 --- /dev/null +++ b/PackagingMallShipper/Services/AuthService.cs @@ -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 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>(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>(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; + } + } + } +} diff --git a/PackagingMallShipper/Services/ExcelService.cs b/PackagingMallShipper/Services/ExcelService.cs new file mode 100644 index 0000000..ca76d1e --- /dev/null +++ b/PackagingMallShipper/Services/ExcelService.cs @@ -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 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 ImportAndShipAsync(string filePath) + { + var ordersToShip = new List(); + int totalRows = 0; + var errors = new List(); + + 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() + }; + } + } +} diff --git a/PackagingMallShipper/Services/Interfaces.cs b/PackagingMallShipper/Services/Interfaces.cs new file mode 100644 index 0000000..b131ddf --- /dev/null +++ b/PackagingMallShipper/Services/Interfaces.cs @@ -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 LoginAsync(string mobile, string password); + string GetToken(); + bool IsLoggedIn { get; } + LocalSession CurrentSession { get; } + void Logout(); + } + + public interface IOrderService + { + Task> GetOrdersAsync(int? status = null, string keyword = null); + Task GetOrderByIdAsync(int orderId); + Task GetOrderByNumberAsync(string orderNumber); + Task GetOrderCountAsync(int? status = null); + } + + public interface ISyncService + { + Task SyncOrdersAsync(SyncMode mode = SyncMode.Incremental); + event Action OnSyncProgress; + event Action OnSyncMessage; + } + + public interface IShipService + { + Task ShipOrderAsync(ShipOrderRequest request); + Task BatchShipOrdersAsync( + List orders, + int concurrency = 3, + CancellationToken cancellationToken = default); + event Action OnShipProgress; + } + + public interface IExcelService + { + Task ExportPendingOrdersAsync(string filePath); + Task ImportAndShipAsync(string filePath); + } +} diff --git a/PackagingMallShipper/Services/OrderService.cs b/PackagingMallShipper/Services/OrderService.cs new file mode 100644 index 0000000..0485234 --- /dev/null +++ b/PackagingMallShipper/Services/OrderService.cs @@ -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> GetOrdersAsync(int? status = null, string keyword = null) + { + return Task.Run(() => + { + var orders = new List(); + 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 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 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 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") + }; + } + } +} diff --git a/PackagingMallShipper/Services/ShipService.cs b/PackagingMallShipper/Services/ShipService.cs new file mode 100644 index 0000000..278e15c --- /dev/null +++ b/PackagingMallShipper/Services/ShipService.cs @@ -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 OnShipProgress; + + public ShipService(IAuthService authService) + { + _authService = authService; + _httpClient = new HttpClient(); + _httpClient.Timeout = TimeSpan.FromSeconds(30); + } + + public async Task 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>(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 BatchShipOrdersAsync( + List orders, + int concurrency = 3, + CancellationToken cancellationToken = default) + { + var result = new BatchShipResult + { + Total = orders.Count, + Results = new List() + }; + + 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(); + } + } + } + } +} diff --git a/PackagingMallShipper/Services/SyncService.cs b/PackagingMallShipper/Services/SyncService.cs new file mode 100644 index 0000000..210af36 --- /dev/null +++ b/PackagingMallShipper/Services/SyncService.cs @@ -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 OnSyncProgress; + public event Action OnSyncMessage; + + public SyncService(IAuthService authService) + { + _authService = authService; + _httpClient = new HttpClient(); + _httpClient.Timeout = TimeSpan.FromSeconds(60); + } + + public async Task 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>(json); + + if (data?.Code != 0) + throw new Exception(data?.Msg ?? "获取订单失败"); + + var orders = data.Data?.OrderList ?? new List(); + 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 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())); + 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(); + } + } + } + } +} diff --git a/PackagingMallShipper/ViewModels/LoginViewModel.cs b/PackagingMallShipper/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..90a4c10 --- /dev/null +++ b/PackagingMallShipper/ViewModels/LoginViewModel.cs @@ -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 + } + } + } +} diff --git a/PackagingMallShipper/ViewModels/MainViewModel.cs b/PackagingMallShipper/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..6c43d0e --- /dev/null +++ b/PackagingMallShipper/ViewModels/MainViewModel.cs @@ -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(); + } + } +} diff --git a/PackagingMallShipper/ViewModels/OrderListViewModel.cs b/PackagingMallShipper/ViewModels/OrderListViewModel.cs new file mode 100644 index 0000000..9a3da29 --- /dev/null +++ b/PackagingMallShipper/ViewModels/OrderListViewModel.cs @@ -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 _orders = new ObservableCollection(); + + [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 + } + } +} diff --git a/PackagingMallShipper/ViewModels/ViewModelBase.cs b/PackagingMallShipper/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..061c9b4 --- /dev/null +++ b/PackagingMallShipper/ViewModels/ViewModelBase.cs @@ -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); + } + } +} diff --git a/PackagingMallShipper/Views/LoginWindow.xaml b/PackagingMallShipper/Views/LoginWindow.xaml new file mode 100644 index 0000000..1254635 --- /dev/null +++ b/PackagingMallShipper/Views/LoginWindow.xaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + +