Files
PackagingMallShipper/轻量级订单发货客户端方案.md
Administrator e7b9d3851c docs: 更新文档中的 .NET Framework 版本为 4.6.2
- 更新 README.md 中的技术栈、系统要求和编译输出路径
- 更新 BUILD_INSTALLER.md 中的兼容性说明和下载链接
- 更新轻量级订单发货客户端方案.md 中的所有版本引用
- 更新 build_installer.bat 自动编译和复制流程
- 修复 App.config 中的 supportedRuntime 版本

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 11:41:46 +08:00

1654 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 包装商城 - 轻量级订单发货客户端技术方案
> **创建日期**: 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.6.2 | 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.6.2
```
✅ Win7原生支持 - Windows 7 SP1 已内置 .NET Framework 4.6.2,无需安装运行时
✅ 性能极佳 - 原生编译,启动时间 <1秒
✅ 内存占用低 - ~50MBvs Electron ~200MB
✅ 包体极小 - ~5MBvs 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.6.2) │
├─────────────────────────────────────────────────────────────┤
│ 视图层 (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.6.2)
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<Order> _orders = new ObservableCollection<Order>();
[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
<!-- Views/OrderListView.xaml -->
<UserControl x:Class="PackagingMallShipper.Views.OrderListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 工具栏 -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="10">
<ComboBox Width="120" SelectedIndex="{Binding SelectedStatus}">
<ComboBoxItem Content="待发货"/>
<ComboBoxItem Content="已发货"/>
<ComboBoxItem Content="全部"/>
</ComboBox>
<TextBox Width="200" Margin="10,0"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
VerticalContentAlignment="Center"/>
<Button Content="搜索" Command="{Binding RefreshOrdersCommand}" Width="80"/>
<Button Content="同步订单" Command="{Binding SyncOrdersCommand}" Margin="10,0" Width="80"/>
<Button Content="导出Excel" Command="{Binding ExportExcelCommand}" Width="80"/>
</StackPanel>
<!-- 订单列表 -->
<DataGrid Grid.Row="1"
ItemsSource="{Binding Orders}"
AutoGenerateColumns="False"
IsReadOnly="True"
SelectionMode="Extended"
CanUserAddRows="False">
<DataGrid.Columns>
<DataGridCheckBoxColumn Binding="{Binding IsSelected}" Width="40"/>
<DataGridTextColumn Header="订单号" Binding="{Binding OrderNumber}" Width="150"/>
<DataGridTextColumn Header="下单时间" Binding="{Binding DateAdd, StringFormat=yyyy-MM-dd HH:mm}" Width="130"/>
<DataGridTextColumn Header="收件人" Binding="{Binding LogisticsName}" Width="80"/>
<DataGridTextColumn Header="电话" Binding="{Binding LogisticsMobile}" Width="110"/>
<DataGridTextColumn Header="地址" Binding="{Binding FullAddress}" Width="*"/>
<DataGridTextColumn Header="金额" Binding="{Binding AmountReal, StringFormat=¥{0:F2}}" Width="80"/>
<DataGridTemplateColumn Header="操作" Width="100">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Content="发货"
Command="{Binding DataContext.ShipOrderCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}"
Padding="10,3"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<!-- 状态栏 -->
<StatusBar Grid.Row="2">
<StatusBarItem>
<TextBlock Text="{Binding Orders.Count, StringFormat=共 {0} 条订单}"/>
</StatusBarItem>
<StatusBarItem>
<ProgressBar Width="100" Height="15"
IsIndeterminate="{Binding IsLoading}"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}"/>
</StatusBarItem>
</StatusBar>
</Grid>
</UserControl>
```
---
## 四、数据模型设计 (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<int, string> Map = new Dictionary<int, string>
{
{ -1, "已关闭" },
{ 0, "待支付" },
{ 1, "待发货" }, // 重点关注
{ 2, "待收货" },
{ 3, "待评价" },
{ 4, "已完成" }
};
public static string GetStatusText(int status)
{
return Map.TryGetValue(status, out var text) ? text : "未知";
}
}
public enum 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<LoginResult> 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<ApiResponse<LoginData>>(json);
if (result?.Code != 0)
{
return new LoginResult
{
Success = false,
Message = result?.Msg ?? "登录失败"
};
}
var token = result.Data.Token;
var uid = result.Data.Uid;
// 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<ApiResponse<UserInfo>>(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<int, int> OnSyncProgress;
public event Action<string> OnSyncMessage;
public SyncService(IAuthService authService)
{
_authService = authService;
_httpClient = new HttpClient();
_httpClient.Timeout = TimeSpan.FromSeconds(60);
}
public async Task<SyncResult> SyncOrdersAsync(SyncMode mode = SyncMode.Incremental)
{
var token = _authService.GetToken();
if (string.IsNullOrEmpty(token))
throw new UnauthorizedAccessException("未登录,请先登录");
var result = new SyncResult();
var syncLog = new SyncLog
{
SyncType = "manual",
SyncMode = mode.ToString().ToLower(),
SyncStart = DateTime.Now,
Status = "running"
};
try
{
// 获取上次同步时间(增量模式)
DateTime? lastSyncTime = null;
if (mode == SyncMode.Incremental)
{
lastSyncTime = GetLastSuccessSyncTime();
}
int page = 1;
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<ApiResponse<OrderListData>>(json);
if (data?.Code != 0)
throw new Exception(data?.Msg ?? "获取订单失败");
var orders = data.Data?.OrderList ?? new List<OrderDto>();
totalPages = data.Data?.TotalPage ?? 1;
if (orders.Count == 0) break;
foreach (var order in orders)
{
var isNew = await SaveOrderAsync(order);
if (isNew) result.NewCount++;
else result.UpdatedCount++;
}
result.TotalCount += orders.Count;
OnSyncProgress?.Invoke(page, totalPages);
if (page >= totalPages) hasMore = false;
else page++;
}
syncLog.Status = "success";
syncLog.SyncEnd = DateTime.Now;
syncLog.TotalCount = result.TotalCount;
syncLog.NewCount = result.NewCount;
syncLog.UpdatedCount = result.UpdatedCount;
OnSyncMessage?.Invoke($"同步完成!新增 {result.NewCount},更新 {result.UpdatedCount}");
}
catch (Exception ex)
{
syncLog.Status = "failed";
syncLog.ErrorMessage = ex.Message;
throw;
}
finally
{
SaveSyncLog(syncLog);
}
return result;
}
private async Task<bool> SaveOrderAsync(OrderDto dto)
{
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<int, int> OnShipProgress;
public ShipService(IAuthService authService)
{
_authService = authService;
_httpClient = new HttpClient();
_httpClient.Timeout = TimeSpan.FromSeconds(30);
}
/// <summary>
/// 单个订单发货
/// </summary>
public async Task<ShipResult> 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<ApiResponse<object>>(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;
}
}
/// <summary>
/// 批量发货(并发控制)
/// </summary>
public async Task<BatchShipResult> BatchShipOrdersAsync(
List<ShipOrderRequest> orders,
int concurrency = 3,
CancellationToken cancellationToken = default)
{
var result = new BatchShipResult
{
Total = orders.Count,
Results = new List<ShipOrderResult>()
};
int completed = 0;
// 使用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;
}
/// <summary>
/// 导出待发货订单
/// </summary>
public async Task<int> 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;
}
/// <summary>
/// 导入发货单号并批量发货
/// </summary>
public async Task<ImportShipResult> ImportAndShipAsync(string filePath)
{
var ordersToShip = new List<ShipOrderRequest>();
int totalRows = 0;
var errors = new List<string>();
using (var workbook = new XLWorkbook(filePath))
{
var worksheet = workbook.Worksheet(1);
var rows = worksheet.RangeUsed().RowsUsed().Skip(1); // 跳过表头
totalRows = rows.Count();
foreach (var row in rows)
{
var orderNumber = row.Cell(1).GetString().Trim();
var expressCompanyName = row.Cell(11).GetString().Trim();
var trackingNumber = row.Cell(12).GetString().Trim();
// 验证必填项
if (string.IsNullOrEmpty(orderNumber))
continue;
if (string.IsNullOrEmpty(trackingNumber))
{
errors.Add($"订单 {orderNumber}: 缺少快递单号");
continue;
}
// 查找本地订单
var order = await 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<List<Order>> GetPendingOrdersAsync()
{
return await Task.Run(() =>
{
var orders = new List<Order>();
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<Order> 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
{
/// <summary>订单ID必填</summary>
public int OrderId { get; set; }
/// <summary>快递公司ID必填-1表示其他</summary>
public int ExpressCompanyId { get; set; }
/// <summary>快递单号</summary>
public string TrackingNumber { get; set; }
/// <summary>订单号(用于显示)</summary>
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
<!-- PackagingMallShipper.csproj -->
<ItemGroup>
<!-- MVVM 框架 -->
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<!-- SQLite 数据库 -->
<PackageReference Include="System.Data.SQLite" Version="1.0.118" />
<!-- JSON 处理 -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<!-- Excel 处理 -->
<PackageReference Include="ClosedXML" Version="0.102.2" />
<!-- 现代UI主题可选 -->
<PackageReference Include="MaterialDesignThemes" Version="4.9.0" />
</ItemGroup>
```
### 8.4 项目文件配置
```xml
<!-- PackagingMallShipper.csproj -->
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{YOUR-GUID-HERE}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>PackagingMallShipper</RootNamespace>
<AssemblyName>PackagingMallShipper</AssemblyName>
<TargetFrameworkVersion>v4.6.2</TargetFrameworkVersion>
<LangVersion>7.3</LangVersion>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
</PropertyGroup>
<!-- 编译配置 -->
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
</Project>
```
---
## 九、开发路线图
### Phase 1 - MVP1.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 预计工作量
- 后端API0.5天
- 前端页面0.5天
---
## 十一、风险与对策
| 风险 | 概率 | 影响 | 对策 |
|-----|:----:|:----:|------|
| API工厂接口变更 | 低 | 高 | 封装API层便于统一修改 |
| Win7特殊兼容问题 | 低 | 低 | .NET Framework 4.6.2 原生支持,风险极低 |
| SQLite性能瓶颈 | 低 | 中 | 添加索引,分页查询,异步操作 |
| 离线发货冲突 | 中 | 中 | 发货前先同步,提示用户确认 |
| VS2019 在 Win7 卡顿 | 中 | 低 | 关闭不必要的扩展,使用轻量配置 |
---
## 十二、附录
### A. 快递公司常用ID
```csharp
// Helpers/ExpressCompanies.cs
public static class ExpressCompanies
{
public static readonly List<ExpressCompany> All = new List<ExpressCompany>
{
new ExpressCompany(1, "顺丰速运", "SF"),
new ExpressCompany(2, "中通快递", "ZTO"),
new ExpressCompany(3, "圆通速递", "YTO"),
new ExpressCompany(4, "韵达快递", "YD"),
new ExpressCompany(5, "申通快递", "STO"),
new ExpressCompany(6, "极兔速递", "JTSD"),
new ExpressCompany(7, "邮政快递包裹", "YZPY"),
new ExpressCompany(8, "EMS", "EMS"),
new ExpressCompany(9, "京东快递", "JD"),
new ExpressCompany(10, "德邦快递", "DBL"),
new ExpressCompany(-1, "其他/自配送", "OTHER")
};
public static string GetName(int id)
{
return All.FirstOrDefault(e => e.Id == id)?.Name ?? "其他";
}
public static int GetIdByName(string name)
{
if (string.IsNullOrEmpty(name)) return -1;
var express = All.FirstOrDefault(e =>
e.Name.Contains(name) || name.Contains(e.Name));
return express?.Id ?? -1;
}
}
public 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.6.2Win7 已内置,无需单独安装)
文件清单:
├── 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
```