# 装箱编号模块 Implementation Plan — Flutter > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** 实现 PRD v1.3 中定义的装箱编号模块 Flutter 页面,支持三种装箱模式(一码一箱、一码多箱、多码一箱),包含扫码识别、信息查询、自动填充、重复校验和提交功能。 **Architecture:** 在现有 Flutter 应用基础上扩展,使用 setState 管理 UI 状态,EventChannel 接收扫码数据,http 包调用后端 API。新增 BoxingPage 和 BoxingDetailPage 两个页面。 **Tech Stack:** Flutter 3.x, Dart, http, shared_preferences, Android native EventChannel (SEUIC scanner) --- ## 页面结构 ``` HomePage ├── 上架登记 (RegistrationPage) ← 已完成 ├── 装箱编号 (BoxingPage) ← 本次实现 │ └── 装箱详情 (BoxingDetailPage) ← 子页面 └── 货架查询 ← 规划中 ``` ## 状态机设计 三种装箱模式对应不同的状态流转: ``` 一码一箱: 等待扫码 → 扫码后自动填充箱号+数量 → 确认 → 成功(1.5s) → 重置等待扫码 一码多箱: 等待扫码 → 扫码后自动填充箱号 → 手动填数量 → 确认 → 成功 → "继续添加": 预填箱号+1、预填上次数量 → 确认 → ... → "返回": 重置等待扫码 多码一箱: 等待扫码 → 扫码后填充+锁定箱号+数量 → 确认 → 成功 → 自动等待下一个扫码 → 箱号锁定 → 自动填充新数量 → 确认 → ... → "返回": 重置等待扫码 ``` --- ### Task 1: ApiService 扩展 — 装箱 API 方法 **Files:** - Modify: `lib/services/api_service.dart` **Step 1: 添加装箱相关的数据类和 API 方法** 在文件末尾(`ApiService` 类内部)新增以下方法,在文件顶部新增数据类: ```dart // === 装箱模块数据类 === /// 装箱信息查询结果 class BoxInfoResult { final bool success; final String? errorMessage; final String? errorCode; final String? zongpaiNo; final String? paichanNo; final int? quantity; final List existingBoxes; final int maxBoxNo; final int suggestedBoxNo; BoxInfoResult({ required this.success, this.errorMessage, this.errorCode, this.zongpaiNo, this.paichanNo, this.quantity, this.existingBoxes = const [], this.maxBoxNo = 0, this.suggestedBoxNo = 1, }); factory BoxInfoResult.ok(Map json) { final boxes = (json['existing_boxes'] as List?) ?.map((b) => BoxDetailData.fromJson(b as Map)) .toList() ?? []; return BoxInfoResult( success: true, zongpaiNo: json['zongpai_no'] as String?, paichanNo: json['paichan_no'] as String?, quantity: json['quantity'] as int?, existingBoxes: boxes, maxBoxNo: json['max_box_no'] as int? ?? 0, suggestedBoxNo: json['suggested_box_no'] as int? ?? 1, ); } factory BoxInfoResult.error(String message, {String? errorCode}) { return BoxInfoResult(success: false, errorMessage: message, errorCode: errorCode); } } /// 箱号明细 class BoxDetailData { final int boxNo; final List items; BoxDetailData({required this.boxNo, required this.items}); factory BoxDetailData.fromJson(Map json) { final items = (json['items'] as List?) ?.map((i) => BoxItemData.fromJson(i as Map)) .toList() ?? []; return BoxDetailData(boxNo: json['box_no'] as int, items: items); } } /// 箱号内单个总排号明细 class BoxItemData { final String zongpaiNo; final int quantity; BoxItemData({required this.zongpaiNo, required this.quantity}); factory BoxItemData.fromJson(Map json) { return BoxItemData( zongpaiNo: json['zongpai_no'] as String, quantity: json['quantity'] as int, ); } } /// 装箱保存结果 class BoxSaveResult { final bool success; final bool isDuplicate; final String? errorMessage; final String? paichanNo; final int? boxNo; BoxSaveResult({ required this.success, this.isDuplicate = false, this.errorMessage, this.paichanNo, this.boxNo, }); factory BoxSaveResult.ok(Map json) { return BoxSaveResult( success: true, paichanNo: json['paichan_no'] as String?, boxNo: json['box_no'] as int?, ); } factory BoxSaveResult.duplicate(Map json) { return BoxSaveResult( success: false, isDuplicate: true, paichanNo: json['paichan_no'] as String?, boxNo: json['box_no'] as int?, ); } factory BoxSaveResult.error(String message) { return BoxSaveResult(success: false, errorMessage: message); } } ``` 在 `ApiService` 类内新增两个方法: ```dart /// 查询装箱信息 — GET /CargoTrace/box/info Future fetchBoxInfo({ required String baseUrl, required String zongpaiNo, }) async { final uri = Uri.parse('$baseUrl/CargoTrace/box/info').replace( queryParameters: {'zongpai_no': zongpaiNo}, ); try { final response = await _client.get(uri, headers: { 'Content-Type': 'application/json', }).timeout(timeout); switch (response.statusCode) { case 200: final body = jsonDecode(response.body) as Map; return BoxInfoResult.ok(body); case 400: final body = jsonDecode(response.body) as Map; final errorCode = body['error_code']?.toString() ?? ''; return BoxInfoResult.error( _errorMessage(errorCode), errorCode: errorCode, ); case 404: return BoxInfoResult.error('未找到该总排号对应的排产号信息'); default: return BoxInfoResult.error('查询失败 (${response.statusCode})'); } } catch (e) { return BoxInfoResult.error('网络异常,请检查网络连接'); } } /// 保存装箱记录 — POST /CargoTrace/box Future saveBoxRecord({ required String baseUrl, required String zongpaiNo, required int boxNo, required int quantity, }) async { final uri = Uri.parse('$baseUrl/CargoTrace/box'); try { final response = await _client .post( uri, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'zongpai_no': zongpaiNo, 'box_no': boxNo, 'quantity': quantity, }), ) .timeout(timeout); switch (response.statusCode) { case 200: final body = jsonDecode(response.body) as Map; return BoxSaveResult.ok(body); case 400: final body = jsonDecode(response.body) as Map; final msg = body['message']?.toString() ?? '请求参数错误'; return BoxSaveResult.error(msg); case 409: final body = jsonDecode(response.body) as Map; return BoxSaveResult.duplicate(body); case 404: return BoxSaveResult.error('未找到该总排号对应的排产号信息'); default: return BoxSaveResult.error('提交失败 (${response.statusCode})'); } } catch (e) { return BoxSaveResult.error('网络异常,请检查网络连接'); } } String _errorMessage(String errorCode) { switch (errorCode) { case 'INVALID_ZONGPAI': return '无效的总排号格式'; default: return '请求参数错误'; } } ``` **Step 2: 验证编译** Run: `flutter analyze lib/services/api_service.dart` **Step 3: Commit** ```bash git add lib/services/api_service.dart git commit -m "feat: add box info query and save methods to ApiService" ``` --- ### Task 2: BoxingDetailPage — 装箱详情页 **Files:** - Create: `lib/pages/boxing_detail_page.dart` **Step 1: 实现详情页** 只读页面,展示排产号下的完整装箱明细(PRD §7.3)。 ```dart import 'package:flutter/material.dart'; import 'package:pad_scanner/services/api_service.dart'; class BoxingDetailPage extends StatelessWidget { final String paichanNo; final List existingBoxes; const BoxingDetailPage({ super.key, required this.paichanNo, required this.existingBoxes, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Row( children: [ const Text('装箱详情'), const SizedBox(width: 12), Text( paichanNo, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), ), ], ), ), body: Column( children: [ Expanded( child: ListView.builder( padding: const EdgeInsets.all(12), itemCount: existingBoxes.length, itemBuilder: (context, index) { final box = existingBoxes[index]; return _buildBoxGroup(context, box); }, ), ), // 底部汇总 Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), color: Theme.of(context).colorScheme.surfaceContainerHighest, child: Text( '共 ${existingBoxes.length} 箱', style: const TextStyle(fontSize: 14), ), ), ], ), ); } Widget _buildBoxGroup(BuildContext context, BoxDetailData box) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '箱号 ${box.boxNo}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), ), const SizedBox(height: 4), Container( width: double.infinity, padding: const EdgeInsets.all(8), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(6), ), child: Column( children: box.items.map((item) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(item.zongpaiNo, style: const TextStyle(fontSize: 14)), Text('数量:${item.quantity}', style: const TextStyle(fontSize: 14)), ], ), ); }).toList(), ), ), ], ), ); } } ``` **Step 2: 验证编译** Run: `flutter analyze lib/pages/boxing_detail_page.dart` **Step 3: Commit** ```bash git add lib/pages/boxing_detail_page.dart git commit -m "feat: add BoxingDetailPage for boxing record details" ``` --- ### Task 3: BoxingPage — 装箱编号主页面 **Files:** - Create: `lib/pages/boxing_page.dart` 这是核心页面,实现 PRD §7 中的所有交互逻辑。 **Step 1: 实现装箱模式枚举和页面** 核心状态变量: - `BoxingMode _mode` — 当前装箱模式 - `_BoxingPhase _phase` — 当前阶段(waiting / scanned / submitted) - `String? _zongpaiNo` — 当前总排号 - `String? _paichanNo` — 排产号 - `int? _erpQuantity` — ERP 中的数量 - `List _existingBoxes` — 已有箱号明细 - `int _maxBoxNo` — 最大箱号 - `TextEditingController _boxNoController` — 箱号输入 - `TextEditingController _quantityController` — 数量输入 - `bool _boxNoLocked` — 箱号是否锁定(多码一箱) - `int? _lastBoxNo` / `int? _lastQuantity` — 上次提交的值(一码多箱用) 页面结构: ``` ┌──────────────────────────────────┐ │ 装箱编号 [一码一箱 ○] │ ← AppBar + 模式切换 ├──────────────────────────────────┤ │ 提示条区域 (错误/警告) │ ← 红色/amber/yellow 提示条 ├──────────────────────────────────┤ │ ✓ 26BW0011 已识别 │ ← 扫码区 │ │ │ 排产号: W00009 │ ← 信息区 │ 已有箱数:3箱 最大箱号:3 │ │ [详情 →] │ │ ─────────────────────────────── │ │ 箱号[ 4 ] 数量[ 80 ] [确认] │ ← 输入区 │ │ ├──────────────────────────────────┤ │ [返回] [继续添加] │ ← 操作栏(一码多箱/多码一箱) ├──────────────────────────────────┤ │ ● 等待扫码 │ ← 状态栏 └──────────────────────────────────┘ ``` **扫码处理逻辑(_onScan):** 1. 解析码值 → 只接受 `CodeType.zongpaiNo` 2. 调用 `fetchBoxInfo` 查询后端 3. 成功后根据模式填充: - 一码一箱:箱号 = suggested_box_no,数量 = erp_quantity - 一码多箱:箱号 = suggested_box_no,数量为空 - 多码一箱(首次):箱号 = suggested_box_no,数量 = erp_quantity,箱号锁定 - 多码一箱(后续):箱号保持锁定,数量 = 新总排号的 erp_quantity **重复箱号检测(本地):** - 当箱号值变化时,比对 `_existingBoxes` 中是否已存在该箱号 - 重复时:amber 边框 + 警告条 + 禁用确认按钮 **提交逻辑(_submit):** 1. 调用 `saveBoxRecord` 保存 2. 成功后根据模式处理后续: - 一码一箱:显示成功 1.5s → 重置 - 一码多箱:记录 lastBoxNo/lastQuantity → 等待"继续添加" - 多码一箱:保留箱号锁定 → 等待下一个扫码 **Step 2: 验证编译** Run: `flutter analyze lib/pages/boxing_page.dart` **Step 3: Commit** ```bash git add lib/pages/boxing_page.dart git commit -m "feat: add BoxingPage with three boxing modes" ``` --- ### Task 4: HomePage 更新 — 接入装箱模块 **Files:** - Modify: `lib/pages/home_page.dart` **Step 1: 更新装箱模块状态** 将 `boxing` FeatureCard 的 `status` 从 `ModuleStatus.developing` 改为 `ModuleStatus.online`,设置 `version: 'v1.0'`。 **Step 2: 更新导航逻辑** 在 `_navigateToModule` 的 `ModuleType.boxing` case 中: - 添加 `import 'package:pad_scanner/pages/boxing_page.dart';` - 替换 placeholder 为 `targetPage = const BoxingPage();` **Step 3: 验证编译** Run: `flutter analyze` **Step 4: Commit** ```bash git add lib/pages/home_page.dart git commit -m "feat: activate boxing module in HomePage" ``` --- ### Task 5: 最终验证 **Step 1: 全量分析** Run: `flutter analyze` Expected: No issues found **Step 2: 运行测试** Run: `flutter test` Expected: All tests pass **Step 3: 检查无遗漏引用** 确认 `BoxingPage` 已正确从 `HomePage` 导航访问。 --- ## 文件变更汇总 | 操作 | 文件 | 说明 | |------|------|------| | Modify | `lib/services/api_service.dart` | 新增装箱 API 方法和数据类 | | Create | `lib/pages/boxing_detail_page.dart` | 装箱详情只读页 | | Create | `lib/pages/boxing_page.dart` | 装箱编号主页面(三种模式) | | Modify | `lib/pages/home_page.dart` | 激活装箱模块导航 |