- 新增 release.bat 一键发布脚本 - 交互式版本号选择(保持/补丁+1/次版本+1/主版本+1/自定义) - 自动更新 csproj 和 setup.iss 中的版本号 - 自动编译、复制文件、生成安装包 - 更新 BUILD_INSTALLER.md 添加发布脚本使用说明 - 修复 轻量级订单发货客户端方案.md 中的 .NET 版本号(4.8 -> 4.6.2) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1654 lines
59 KiB
Markdown
1654 lines
59 KiB
Markdown
# 包装商城 - 轻量级订单发货客户端技术方案
|
||
|
||
> **创建日期**: 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秒
|
||
✅ 内存占用低 - ~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.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.6.2 | Win7 SP1 支持 |
|
||
| 数据库 | 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 - 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.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.2(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
|
||
```
|