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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PackagingMallShipper/Views/LoginWindow.xaml.cs b/PackagingMallShipper/Views/LoginWindow.xaml.cs
new file mode 100644
index 0000000..b91d7ab
--- /dev/null
+++ b/PackagingMallShipper/Views/LoginWindow.xaml.cs
@@ -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();
+ }
+ }
+}
diff --git a/PackagingMallShipper/Views/MainWindow.xaml b/PackagingMallShipper/Views/MainWindow.xaml
new file mode 100644
index 0000000..420d2a5
--- /dev/null
+++ b/PackagingMallShipper/Views/MainWindow.xaml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PackagingMallShipper/Views/MainWindow.xaml.cs b/PackagingMallShipper/Views/MainWindow.xaml.cs
new file mode 100644
index 0000000..681d1eb
--- /dev/null
+++ b/PackagingMallShipper/Views/MainWindow.xaml.cs
@@ -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();
+ };
+ }
+ }
+}
diff --git a/PackagingMallShipper/Views/OrderListView.xaml b/PackagingMallShipper/Views/OrderListView.xaml
new file mode 100644
index 0000000..048ff4b
--- /dev/null
+++ b/PackagingMallShipper/Views/OrderListView.xaml
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PackagingMallShipper/Views/OrderListView.xaml.cs b/PackagingMallShipper/Views/OrderListView.xaml.cs
new file mode 100644
index 0000000..3d0933e
--- /dev/null
+++ b/PackagingMallShipper/Views/OrderListView.xaml.cs
@@ -0,0 +1,12 @@
+using System.Windows.Controls;
+
+namespace PackagingMallShipper.Views
+{
+ public partial class OrderListView : UserControl
+ {
+ public OrderListView()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/PackagingMallShipper/Views/ShippingDialog.xaml b/PackagingMallShipper/Views/ShippingDialog.xaml
new file mode 100644
index 0000000..915b010
--- /dev/null
+++ b/PackagingMallShipper/Views/ShippingDialog.xaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PackagingMallShipper/Views/ShippingDialog.xaml.cs b/PackagingMallShipper/Views/ShippingDialog.xaml.cs
new file mode 100644
index 0000000..14f1d8e
--- /dev/null
+++ b/PackagingMallShipper/Views/ShippingDialog.xaml.cs
@@ -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();
+ }
+ }
+}
diff --git a/轻量级订单发货客户端方案.md b/轻量级订单发货客户端方案.md
new file mode 100644
index 0000000..4933a6d
--- /dev/null
+++ b/轻量级订单发货客户端方案.md
@@ -0,0 +1,1653 @@
+# 包装商城 - 轻量级订单发货客户端技术方案
+
+> **创建日期**: 2025-12-17
+> **更新日期**: 2025-12-17
+> **项目名称**: 包装商城发货助手
+> **项目定位**: 面向子账号管理员的轻量级桌面客户端,专注订单发货流程
+> **开发策略**: 并行开发(当前项目补充发货API + 独立客户端)
+
+---
+
+## 一、项目概述
+
+### 1.1 背景
+
+包装商城管理系统(Web端)已具备完整的订单同步、查询、导出功能,但**缺少发货功能**。一线发货人员需要:
+
+- 快速查看待发货订单
+- 批量填写快递单号并发货
+- Excel导出/导入发货数据
+- 离线环境下也能操作
+
+### 1.2 目标用户
+
+- **子账号管理员**:各分公司/门店的发货专员
+- **使用场景**:日常发货操作、批量处理、数据导出
+
+### 1.3 核心功能
+
+| 功能模块 | 描述 | 优先级 |
+|---------|------|:------:|
+| 子账号登录 | API工厂认证 + 本地会话 | P0 |
+| 订单列表 | 本地缓存 + 增量同步 | P0 |
+| 单个发货 | 填写快递单号发货 | P0 |
+| 批量发货 | 多订单同时发货 | P0 |
+| Excel导出 | 导出待发货订单 | P1 |
+| Excel导入 | 导入快递单号批量发货 | P1 |
+| 离线操作 | 本地SQLite支持 | P2 |
+
+---
+
+## 二、技术选型
+
+### 2.1 技术栈
+
+| 层级 | 选型 | 版本 | 理由 |
+|-----|------|------|------|
+| **桌面框架** | WPF | .NET Framework 4.8 | Win7原生支持、性能极佳、微软官方维护 |
+| **开发语言** | C# | 7.3 | 强类型、性能优秀、生态成熟 |
+| **UI风格** | Modern WPF | MaterialDesign/MahApps | 现代Windows风格 |
+| **架构模式** | MVVM | CommunityToolkit.Mvvm | 解耦视图与逻辑 |
+| **本地数据库** | SQLite | System.Data.SQLite | 零配置、单文件、高性能 |
+| **Excel处理** | ClosedXML | 0.102.x | 功能完整、无COM依赖 |
+| **HTTP客户端** | HttpClient | 内置 | 原生支持、异步友好 |
+| **JSON处理** | Newtonsoft.Json | 13.x | 功能强大、兼容性好 |
+
+### 2.2 为什么选择 WPF + .NET Framework 4.8?
+
+```
+✅ Win7原生支持 - Windows 7 SP1 已内置 .NET Framework 4.8,无需安装运行时
+✅ 性能极佳 - 原生编译,启动时间 <1秒
+✅ 内存占用低 - ~50MB(vs Electron ~200MB)
+✅ 包体极小 - ~5MB(vs Electron ~150MB)
+✅ 微软官方维护 - 长期支持,稳定可靠
+✅ XAML布局 - 声明式UI,易于维护
+✅ 开发工具成熟 - Visual Studio 2019 完美支持
+✅ AI友好 - C#/WPF 是 AI 非常擅长的技术栈
+```
+
+### 2.3 为什么选择 SQLite?
+
+```
+✅ 零配置 - 无需安装数据库服务,开箱即用
+✅ 单文件 - 数据库即文件,便于备份和迁移
+✅ 高性能 - 本地读写,10万+订单秒级查询
+✅ 离线支持 - 无网络时仍可查看缓存数据
+✅ 跨平台 - Windows/Mac/Linux 通用
+✅ 嵌入式 - 随应用打包,无额外依赖
+```
+
+### 2.4 与当前Web项目的关系
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ API工厂 (远程) │
+│ user.api.it120.cc / common.apifm.com │
+└────────────────────────┬────────────────────────────────────┘
+ │
+ ┌───────────────┼───────────────┐
+ │ │ │
+ ▼ ▼ ▼
+┌─────────────┐ ┌─────────────┐ ┌─────────────┐
+│ 当前Web项目 │ │ 轻量级客户端 │ │ 其他客户端 │
+│ (管理后台) │ │ (发货专用) │ │ (未来) │
+│ │ │ │ │ │
+│ MySQL本地缓存│ │ SQLite本地 │ │ ... │
+└─────────────┘ └─────────────┘ └─────────────┘
+```
+
+**共用API工厂接口,数据一致性由API工厂保证。**
+
+---
+
+## 三、系统架构
+
+### 3.1 整体架构(WPF MVVM)
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 桌面客户端 (WPF + .NET Framework 4.8) │
+├─────────────────────────────────────────────────────────────┤
+│ 视图层 (Views/XAML) │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ LoginWindow │ │OrderListView│ │ShippingView │ │
+│ └─────────────┘ └─────────────┘ └─────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ 视图模型层 (ViewModels) │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ LoginVM │ │ OrderListVM │ │ ShippingVM │ │
+│ └─────────────┘ └─────────────┘ └─────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ 服务层 (Services) │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ AuthService │ │ OrderService│ │ ShipService │ │
+│ └─────────────┘ └─────────────┘ └─────────────┘ │
+│ ┌─────────────┐ ┌─────────────┐ │
+│ │ SyncService │ │ExcelService │ │
+│ └─────────────┘ └─────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ 数据访问层 │
+│ ┌─────────────────────┐ ┌─────────────────────┐ │
+│ │ SQLite │ │ ApiFactoryClient │ │
+│ │ (System.Data.SQLite)│ │ 远程API调用 │ │
+│ └─────────────────────┘ └─────────────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 3.2 MVVM数据绑定
+
+```csharp
+// ViewModel -> View 数据绑定示例
+// 使用 CommunityToolkit.Mvvm (兼容 .NET Framework 4.8)
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using System.Collections.ObjectModel;
+using System.Threading.Tasks;
+
+public partial class OrderListViewModel : ObservableObject
+{
+ private readonly IOrderService _orderService;
+ private readonly IShipService _shipService;
+
+ [ObservableProperty]
+ private ObservableCollection _orders = new ObservableCollection();
+
+ [ObservableProperty]
+ private bool _isLoading;
+
+ [ObservableProperty]
+ private string _searchText = "";
+
+ [ObservableProperty]
+ private int _selectedStatus = 1; // 默认待发货
+
+ public OrderListViewModel(IOrderService orderService, IShipService shipService)
+ {
+ _orderService = orderService;
+ _shipService = shipService;
+ }
+
+ [RelayCommand]
+ private async Task RefreshOrdersAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ var result = await _orderService.GetOrdersAsync(SelectedStatus, SearchText);
+ Orders.Clear();
+ foreach (var order in result)
+ {
+ Orders.Add(order);
+ }
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task ShipOrderAsync(Order order)
+ {
+ if (order == null) return;
+
+ var dialog = new ShippingDialog(order);
+ if (dialog.ShowDialog() == true)
+ {
+ await _shipService.ShipOrderAsync(new ShipOrderRequest
+ {
+ OrderId = order.Id,
+ ExpressCompanyId = dialog.SelectedExpressId,
+ TrackingNumber = dialog.TrackingNumber
+ });
+ await RefreshOrdersAsync();
+ }
+ }
+}
+```
+
+### 3.3 XAML视图示例
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## 四、数据模型设计 (SQLite)
+
+### 4.1 核心表结构
+
+```sql
+-- 1. 本地用户会话
+CREATE TABLE local_session (
+ id INTEGER PRIMARY KEY,
+ mobile TEXT NOT NULL,
+ token TEXT NOT NULL,
+ nickname TEXT,
+ enterprise_id INTEGER,
+ permissions TEXT,
+ last_login_at DATETIME,
+ token_expires_at DATETIME
+);
+
+-- 2. 订单缓存表(核心)
+CREATE TABLE orders_cache (
+ id INTEGER PRIMARY KEY,
+ order_number TEXT UNIQUE NOT NULL,
+ status INTEGER NOT NULL,
+ amount REAL,
+ amount_real REAL,
+
+ -- 下单用户
+ uid INTEGER,
+ user_mobile TEXT,
+ user_nick TEXT,
+
+ -- 收货信息
+ logistics_name TEXT,
+ logistics_mobile TEXT,
+ logistics_province TEXT,
+ logistics_city TEXT,
+ logistics_district TEXT,
+ logistics_address TEXT,
+
+ -- 商品信息(JSON)
+ goods_json TEXT,
+
+ -- 发货信息
+ express_company_id INTEGER,
+ express_company_name TEXT,
+ tracking_number TEXT,
+ date_ship DATETIME,
+
+ -- 同步状态
+ sync_status TEXT DEFAULT 'synced', -- synced/pending_ship/shipping/failed
+ local_updated_at DATETIME,
+
+ -- 时间戳
+ date_add DATETIME,
+ date_pay DATETIME,
+ date_update DATETIME,
+ synced_at DATETIME
+);
+
+-- 3. 发货任务队列(离线发货支持)
+CREATE TABLE ship_queue (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ order_id INTEGER NOT NULL,
+ order_number TEXT NOT NULL,
+ express_company_id INTEGER NOT NULL,
+ express_company_name TEXT,
+ tracking_number TEXT NOT NULL,
+ status TEXT DEFAULT 'pending', -- pending/processing/success/failed
+ retry_count INTEGER DEFAULT 0,
+ error_message TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ processed_at DATETIME,
+ FOREIGN KEY (order_id) REFERENCES orders_cache(id)
+);
+
+-- 4. 快递公司字典(本地缓存)
+CREATE TABLE express_companies (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ code TEXT,
+ is_common INTEGER DEFAULT 0
+);
+
+-- 5. 操作日志
+CREATE TABLE operation_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ action TEXT NOT NULL,
+ target_id INTEGER,
+ target_number TEXT,
+ details TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+-- 6. 同步记录
+CREATE TABLE sync_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sync_type TEXT NOT NULL, -- manual/scheduled/startup
+ sync_mode TEXT NOT NULL, -- full/incremental
+ sync_start DATETIME,
+ 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 DEFAULT 'running',
+ error_message TEXT
+);
+
+-- 索引优化
+CREATE INDEX idx_orders_status ON orders_cache(status);
+CREATE INDEX idx_orders_sync_status ON orders_cache(sync_status);
+CREATE INDEX idx_orders_date_add ON orders_cache(date_add);
+CREATE INDEX idx_orders_order_number ON orders_cache(order_number);
+CREATE INDEX idx_ship_queue_status ON ship_queue(status);
+```
+
+### 4.2 订单状态映射
+
+```csharp
+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 SyncStatus
+{
+ Synced, // 已同步
+ PendingShip, // 待发货(本地)
+ Shipping, // 发货中
+ Failed // 发货失败
+}
+```
+
+---
+
+## 五、核心功能实现
+
+### 5.1 数据库访问层
+
+```csharp
+// Data/SqliteHelper.cs
+using System.Data.SQLite;
+using System.IO;
+
+public class SqliteHelper
+{
+ private static string _connectionString;
+
+ public static void Initialize()
+ {
+ var 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))
+ {
+ CreateTables();
+ }
+ }
+
+ public static SQLiteConnection GetConnection()
+ {
+ return new SQLiteConnection(_connectionString);
+ }
+
+ 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();
+ }
+ }
+ }
+}
+```
+
+### 5.2 登录认证
+
+```csharp
+// Services/AuthService.cs
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+public class AuthService : IAuthService
+{
+ private readonly HttpClient _httpClient;
+ private const string BaseUrl = "https://user.api.it120.cc";
+
+ private LocalSession _currentSession;
+
+ public AuthService()
+ {
+ _httpClient = new HttpClient();
+ _httpClient.Timeout = TimeSpan.FromSeconds(30);
+ }
+
+ public async Task LoginAsync(string mobile, string password)
+ {
+ try
+ {
+ // 1. 调用API工厂登录
+ var url = $"{BaseUrl}/{AppConfig.SubDomain}/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;
+
+ // 2. 获取用户信息
+ _httpClient.DefaultRequestHeaders.Clear();
+ _httpClient.DefaultRequestHeaders.Add("X-Token", token);
+
+ var userInfoUrl = $"{BaseUrl}/{AppConfig.SubDomain}/user/detail";
+ var userResponse = await _httpClient.GetAsync(userInfoUrl);
+ var userJson = await userResponse.Content.ReadAsStringAsync();
+ var userResult = JsonConvert.DeserializeObject>(userJson);
+
+ // 3. 保存到本地SQLite
+ _currentSession = new LocalSession
+ {
+ Id = 1,
+ Mobile = mobile,
+ Token = token,
+ Uid = uid,
+ Nickname = userResult?.Data?.Nick ?? mobile,
+ LastLoginAt = DateTime.Now,
+ TokenExpiresAt = DateTime.Now.AddHours(24)
+ };
+
+ 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)
+ _currentSession = LoadSession();
+
+ if (_currentSession == null)
+ return null;
+ if (_currentSession.TokenExpiresAt < DateTime.Now)
+ return null;
+
+ return _currentSession.Token;
+ }
+
+ public bool IsLoggedIn => !string.IsNullOrEmpty(GetToken());
+
+ private void SaveSession(LocalSession session)
+ {
+ using (var conn = SqliteHelper.GetConnection())
+ {
+ conn.Open();
+ var sql = @"INSERT OR REPLACE INTO local_session
+ (id, mobile, token, nickname, last_login_at, token_expires_at)
+ VALUES (@id, @mobile, @token, @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("@nickname", session.Nickname);
+ cmd.Parameters.AddWithValue("@lastLogin", session.LastLoginAt);
+ cmd.Parameters.AddWithValue("@expires", session.TokenExpiresAt);
+ cmd.ExecuteNonQuery();
+ }
+ }
+ }
+
+ private LocalSession LoadSession()
+ {
+ 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())
+ {
+ return new LocalSession
+ {
+ Id = reader.GetInt32(0),
+ Mobile = reader.GetString(1),
+ Token = reader.GetString(2),
+ Nickname = reader.IsDBNull(3) ? null : reader.GetString(3),
+ LastLoginAt = reader.GetDateTime(6),
+ TokenExpiresAt = reader.GetDateTime(7)
+ };
+ }
+ }
+ }
+ return null;
+ }
+}
+```
+
+### 5.3 订单同步
+
+```csharp
+// Services/SyncService.cs
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+public class SyncService : ISyncService
+{
+ private readonly IAuthService _authService;
+ private readonly HttpClient _httpClient;
+ private const string BaseUrl = "https://user.api.it120.cc";
+
+ 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;
+ const int pageSize = 50;
+ bool hasMore = true;
+ int totalPages = 1;
+
+ while (hasMore)
+ {
+ OnSyncMessage?.Invoke($"正在同步第 {page}/{totalPages} 页...");
+
+ var url = $"{BaseUrl}/{AppConfig.SubDomain}/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)
+ {
+ 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));
+ 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();
+ }
+ }
+ }
+}
+
+public enum SyncMode
+{
+ Full,
+ Incremental
+}
+```
+
+### 5.4 发货功能
+
+```csharp
+// Services/ShipService.cs
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+public class ShipService : IShipService
+{
+ private readonly IAuthService _authService;
+ private readonly HttpClient _httpClient;
+ private const string BaseUrl = "https://user.api.it120.cc";
+
+ 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("未登录");
+
+ // 1. 更新本地状态为"发货中"
+ UpdateLocalOrderStatus(request.OrderId, SyncStatus.Shipping);
+
+ try
+ {
+ // 2. 调用API工厂发货接口
+ var url = $"{BaseUrl}/{AppConfig.SubDomain}/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 ?? "发货失败");
+
+ // 3. 更新本地订单状态
+ UpdateLocalOrderAfterShip(request);
+
+ return new ShipResult { Success = true };
+ }
+ catch (Exception ex)
+ {
+ // 发货失败,更新状态
+ UpdateLocalOrderStatus(request.OrderId, SyncStatus.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;
+
+ // 使用SemaphoreSlim控制并发
+ 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, SyncStatus 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.ToString());
+ 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();
+ }
+ }
+ }
+}
+```
+
+### 5.5 Excel导出/导入
+
+```csharp
+// Services/ExcelService.cs
+using ClosedXML.Excel;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+public class ExcelService : IExcelService
+{
+ private readonly IShipService _shipService;
+
+ public ExcelService(IShipService shipService)
+ {
+ _shipService = shipService;
+ }
+
+ ///
+ /// 导出待发货订单
+ ///
+ public async Task ExportPendingOrdersAsync(string filePath)
+ {
+ var orders = await GetPendingOrdersAsync();
+
+ 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 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()
+ };
+ }
+
+ private async Task> GetPendingOrdersAsync()
+ {
+ return await Task.Run(() =>
+ {
+ var orders = new List();
+ using (var conn = SqliteHelper.GetConnection())
+ {
+ conn.Open();
+ var sql = @"SELECT * FROM orders_cache
+ WHERE status = 1
+ ORDER BY date_add DESC";
+ using (var cmd = new SQLiteCommand(sql, conn))
+ using (var reader = cmd.ExecuteReader())
+ {
+ while (reader.Read())
+ {
+ orders.Add(MapOrder(reader));
+ }
+ }
+ }
+ return orders;
+ });
+ }
+
+ private async Task GetOrderByNumberAsync(string orderNumber)
+ {
+ return await 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;
+ });
+ }
+
+ private Order MapOrder(SQLiteDataReader reader)
+ {
+ return new Order
+ {
+ Id = reader.GetInt32(reader.GetOrdinal("id")),
+ OrderNumber = reader.GetString(reader.GetOrdinal("order_number")),
+ Status = reader.GetInt32(reader.GetOrdinal("status")),
+ AmountReal = reader.GetDecimal(reader.GetOrdinal("amount_real")),
+ 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"),
+ DateAdd = reader.GetDateTimeSafe("date_add")
+ };
+ }
+}
+
+// 扩展方法
+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 DateTime? GetDateTimeSafe(this SQLiteDataReader reader, string column)
+ {
+ var ordinal = reader.GetOrdinal(column);
+ return reader.IsDBNull(ordinal) ? (DateTime?)null : reader.GetDateTime(ordinal);
+ }
+}
+```
+
+---
+
+## 六、项目目录结构
+
+```
+PackagingMallShipper/
+├── PackagingMallShipper.sln # 解决方案文件
+├── PackagingMallShipper/ # 主项目
+│ ├── App.xaml # 应用入口XAML
+│ ├── App.xaml.cs # 应用入口代码
+│ ├── App.config # 应用配置文件
+│ │
+│ ├── Models/ # 数据模型
+│ │ ├── Order.cs # 订单模型
+│ │ ├── LocalSession.cs # 本地会话
+│ │ ├── ShipQueue.cs # 发货队列
+│ │ ├── SyncLog.cs # 同步日志
+│ │ └── ApiResponses.cs # API响应模型
+│ │
+│ ├── ViewModels/ # 视图模型(MVVM)
+│ │ ├── ViewModelBase.cs # VM基类
+│ │ ├── MainViewModel.cs # 主窗口VM
+│ │ ├── LoginViewModel.cs # 登录VM
+│ │ ├── OrderListViewModel.cs # 订单列表VM
+│ │ └── ShippingViewModel.cs # 发货VM
+│ │
+│ ├── Views/ # 视图(XAML)
+│ │ ├── MainWindow.xaml # 主窗口
+│ │ ├── LoginWindow.xaml # 登录窗口
+│ │ ├── OrderListView.xaml # 订单列表(UserControl)
+│ │ ├── ShippingDialog.xaml # 发货对话框
+│ │ └── Controls/ # 自定义控件
+│ │ ├── LoadingSpinner.xaml # 加载动画
+│ │ └── StatusBadge.xaml # 状态标签
+│ │
+│ ├── Services/ # 业务服务
+│ │ ├── IAuthService.cs # 认证接口
+│ │ ├── AuthService.cs # 认证实现
+│ │ ├── IOrderService.cs # 订单接口
+│ │ ├── OrderService.cs # 订单实现
+│ │ ├── ISyncService.cs # 同步接口
+│ │ ├── SyncService.cs # 同步实现
+│ │ ├── IShipService.cs # 发货接口
+│ │ ├── ShipService.cs # 发货实现
+│ │ └── ExcelService.cs # Excel导入导出
+│ │
+│ ├── Data/ # 数据访问
+│ │ ├── SqliteHelper.cs # SQLite帮助类
+│ │ └── Resources/ # 嵌入资源
+│ │ └── schema.sql # 数据库初始化脚本
+│ │
+│ ├── Helpers/ # 工具类
+│ │ ├── ExpressCompanies.cs # 快递公司字典
+│ │ ├── AppConfig.cs # 应用配置
+│ │ └── ResourceHelper.cs # 资源帮助类
+│ │
+│ ├── Converters/ # 值转换器
+│ │ ├── BoolToVisibilityConverter.cs
+│ │ ├── StatusToColorConverter.cs
+│ │ └── StatusToTextConverter.cs
+│ │
+│ ├── Resources/ # 资源文件
+│ │ ├── Styles.xaml # 全局样式
+│ │ ├── Colors.xaml # 颜色定义
+│ │ └── Icons/ # 图标资源
+│ │ └── app.ico # 应用图标
+│ │
+│ ├── Properties/
+│ │ ├── AssemblyInfo.cs # 程序集信息
+│ │ └── Resources.resx # 资源文件
+│ │
+│ └── PackagingMallShipper.csproj # 项目文件
+│
+└── README.md # 项目说明
+```
+
+---
+
+## 七、API工厂接口清单
+
+### 7.1 认证接口
+
+| 接口 | 方法 | 域名 | 说明 |
+|-----|------|------|------|
+| `/{subDomain}/user/m/login` | POST | user.api.it120.cc | 手机号登录 |
+| `/{subDomain}/user/detail` | GET | user.api.it120.cc | 获取用户信息 |
+
+### 7.2 订单接口
+
+| 接口 | 方法 | 域名 | 说明 |
+|-----|------|------|------|
+| `/{subDomain}/order/list` | GET | user.api.it120.cc | 订单列表 |
+| `/{subDomain}/order/detail` | GET | user.api.it120.cc | 订单详情 |
+| `/{subDomain}/order/delivery` | POST | user.api.it120.cc | 订单发货 |
+
+### 7.3 发货接口参数
+
+```csharp
+// POST /{subDomain}/order/delivery
+public class ShipOrderRequest
+{
+ /// 订单ID(必填)
+ public int OrderId { get; set; }
+
+ /// 快递公司ID(必填,-1表示其他)
+ public int ExpressCompanyId { get; set; }
+
+ /// 快递单号
+ public string TrackingNumber { get; set; }
+
+ /// 订单号(用于显示)
+ public string OrderNumber { get; set; }
+}
+```
+
+---
+
+## 八、开发环境配置
+
+### 8.1 开发环境要求
+
+| 项目 | 要求 | 说明 |
+|-----|------|------|
+| 操作系统 | Windows 7 SP1+ | 开发和运行环境 |
+| IDE | Visual Studio 2019 | 社区版即可(免费) |
+| .NET Framework | 4.8 | Win7 已内置 |
+| 数据库 | SQLite | 自动创建,无需安装 |
+
+### 8.2 Visual Studio 2019 安装
+
+```
+下载地址:https://visualstudio.microsoft.com/vs/older-downloads/
+选择版本:Visual Studio 2019 Community(免费)
+
+安装工作负载:
+✅ .NET 桌面开发
+✅ 通用 Windows 平台开发(可选)
+```
+
+### 8.3 NuGet 包依赖
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 8.4 项目文件配置
+
+```xml
+
+
+
+
+ Debug
+ AnyCPU
+ {YOUR-GUID-HERE}
+ WinExe
+ PackagingMallShipper
+ PackagingMallShipper
+ v4.8
+ 7.3
+ true
+ true
+
+
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+```
+
+---
+
+## 九、开发路线图
+
+### Phase 1 - MVP(1.5周)
+
+| 任务 | 预计工作量 | 状态 |
+|-----|-----------|:----:|
+| VS2019 环境搭建 + WPF项目创建 | 0.5天 | ⬜ |
+| SQLite + 数据库初始化 | 0.5天 | ⬜ |
+| MVVM架构搭建(ViewModelBase等) | 0.5天 | ⬜ |
+| 登录功能(API工厂认证 + 本地存储) | 1天 | ⬜ |
+| 订单列表(本地查询 + DataGrid) | 1天 | ⬜ |
+| 订单同步(全量/增量) | 1天 | ⬜ |
+| 单个订单发货 | 1天 | ⬜ |
+| 基础UI界面美化 | 1天 | ⬜ |
+
+### Phase 2 - 核心功能(1周)
+
+| 任务 | 预计工作量 | 状态 |
+|-----|-----------|:----:|
+| 批量发货(并发控制 + 进度显示) | 1天 | ⬜ |
+| Excel导出待发货订单 | 0.5天 | ⬜ |
+| Excel导入发货单号 + 批量发货 | 1天 | ⬜ |
+| 发货结果统计与错误提示 | 0.5天 | ⬜ |
+| 订单搜索与筛选 | 0.5天 | ⬜ |
+| 异常处理与用户提示完善 | 0.5天 | ⬜ |
+
+### Phase 3 - 完善(0.5周)
+
+| 任务 | 预计工作量 | 状态 |
+|-----|-----------|:----:|
+| 离线队列支持(网络恢复自动重试) | 1天 | ⬜ |
+| 定时自动同步(后台任务) | 0.5天 | ⬜ |
+| 快递公司常用列表优化 | 0.5天 | ⬜ |
+| 打包发布(Release编译 + 安装包) | 0.5天 | ⬜ |
+| 测试与Bug修复 | 0.5天 | ⬜ |
+
+---
+
+## 十、当前Web项目补充(并行)
+
+在开发客户端的同时,当前Web项目需补充发货API:
+
+### 10.1 需要新增的代码
+
+| 文件 | 新增内容 |
+|-----|---------|
+| `OrderService.ts` | `shipOrder()`, `batchShipOrders()` |
+| `OrderController.ts` | `shipOrder()`, `batchShip()` |
+| `order.routes.ts` | `POST /api/ship`, `POST /api/batch-ship` |
+| `order/index.ejs` | 发货按钮、发货弹窗 |
+
+### 10.2 预计工作量
+
+- 后端API:0.5天
+- 前端页面:0.5天
+
+---
+
+## 十一、风险与对策
+
+| 风险 | 概率 | 影响 | 对策 |
+|-----|:----:|:----:|------|
+| API工厂接口变更 | 低 | 高 | 封装API层,便于统一修改 |
+| Win7特殊兼容问题 | 低 | 低 | .NET Framework 4.8 原生支持,风险极低 |
+| SQLite性能瓶颈 | 低 | 中 | 添加索引,分页查询,异步操作 |
+| 离线发货冲突 | 中 | 中 | 发货前先同步,提示用户确认 |
+| VS2019 在 Win7 卡顿 | 中 | 低 | 关闭不必要的扩展,使用轻量配置 |
+
+---
+
+## 十二、附录
+
+### A. 快递公司常用ID
+
+```csharp
+// Helpers/ExpressCompanies.cs
+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 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;
+ }
+}
+```
+
+### B. 参考文档
+
+- [WPF 官方文档](https://docs.microsoft.com/zh-cn/dotnet/desktop/wpf/)
+- [CommunityToolkit.Mvvm](https://learn.microsoft.com/zh-cn/dotnet/communitytoolkit/mvvm/)
+- [System.Data.SQLite](https://system.data.sqlite.org/index.html/doc/trunk/www/index.wiki)
+- [ClosedXML 文档](https://closedxml.github.io/ClosedXML/)
+- [Newtonsoft.Json](https://www.newtonsoft.com/json/help/html/Introduction.htm)
+- [API工厂接口文档](../docs/后台_接口API.json)
+
+### C. 部署说明
+
+```
+发布步骤:
+1. Visual Studio 中选择 Release 配置
+2. 生成 → 生成解决方案
+3. 复制 bin/Release 目录下的所有文件
+4. 打包成 zip 或使用 Inno Setup 制作安装包
+
+运行环境要求:
+- Windows 7 SP1 或更高版本
+- .NET Framework 4.8(Win7 已内置,无需单独安装)
+
+文件清单:
+├── PackagingMallShipper.exe # 主程序
+├── PackagingMallShipper.exe.config
+├── System.Data.SQLite.dll # SQLite 核心库
+├── SQLite.Interop.dll # SQLite 原生库(x86/x64)
+├── Newtonsoft.Json.dll
+├── ClosedXML.dll
+├── DocumentFormat.OpenXml.dll
+└── CommunityToolkit.Mvvm.dll
+```