diff --git a/lib/pages/registration_page.dart b/lib/pages/registration_page.dart index 7a7df5a..c952ff2 100644 --- a/lib/pages/registration_page.dart +++ b/lib/pages/registration_page.dart @@ -16,7 +16,7 @@ class _RegistrationPageState extends State { final _scannerService = ScannerService(); final _apiService = ApiService(); - String? _zongpaiNo; + final _zongpaiNos = []; String? _locationCode; CodeType? _locationType; bool _isLocked = false; @@ -37,7 +37,22 @@ class _RegistrationPageState extends State { switch (parsed.type) { case CodeType.zongpaiNo: setState(() { - _zongpaiNo = parsed.value; + if (_isLocked) { + // 锁定模式:追加到列表(重复则覆盖) + final idx = _zongpaiNos.indexOf(parsed.value); + if (idx >= 0) { + _zongpaiNos[idx] = parsed.value; + } else { + _zongpaiNos.add(parsed.value); + } + } else { + // 单次模式:只有一条,覆盖 + if (_zongpaiNos.isNotEmpty) { + _zongpaiNos[0] = parsed.value; + } else { + _zongpaiNos.add(parsed.value); + } + } _snackbarMessage = null; _successMessage = null; }); @@ -52,10 +67,16 @@ class _RegistrationPageState extends State { }); } case CodeType.invalid: - _showFeedback('无效码,请重新扫描', isError: true); + _showFeedback('无效码:${result.barcode}', isError: true); } } + void _removeZongpai(int index) { + setState(() { + _zongpaiNos.removeAt(index); + }); + } + void _showFeedback(String message, {bool isError = false}) { setState(() { _snackbarMessage = message; @@ -69,10 +90,10 @@ class _RegistrationPageState extends State { String get _statusText { if (_isSubmitting) return '正在提交…'; if (_successMessage != null) return _successMessage!; - if (_isLocked && _locationCode != null && _zongpaiNo == null) { + if (_isLocked && _locationCode != null && _zongpaiNos.isEmpty) { return '货位已锁定,请扫描下一张执行卡'; } - final hasZ = _zongpaiNo != null; + final hasZ = _zongpaiNos.isNotEmpty; final hasL = _locationCode != null; if (hasZ && hasL) return '请确认信息并提交'; if (hasZ && !hasL) return '请扫描目标货位号'; @@ -81,7 +102,7 @@ class _RegistrationPageState extends State { } bool get _canSubmit => - _zongpaiNo != null && _locationCode != null && !_isSubmitting; + _zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting; Future _submit() async { if (!_canSubmit) return; @@ -95,9 +116,19 @@ class _RegistrationPageState extends State { setState(() => _isSubmitting = true); + if (_isLocked && _zongpaiNos.length > 1) { + // 批量提交:逐个提交列表中的总排号 + await _submitBatch(baseUrl); + } else { + // 单条提交 + await _submitOne(baseUrl, _zongpaiNos.first); + } + } + + Future _submitOne(String baseUrl, String zongpaiNo) async { final result = await _apiService.registerLocation( baseUrl: baseUrl, - zongpaiNo: _zongpaiNo!, + zongpaiNo: zongpaiNo, locationCode: _locationCode!, ); @@ -107,7 +138,7 @@ class _RegistrationPageState extends State { if (result.success) { setState(() { - _zongpaiNo = null; + _zongpaiNos.remove(zongpaiNo); if (!_isLocked) { _locationCode = null; _locationType = null; @@ -120,13 +151,55 @@ class _RegistrationPageState extends State { if (mounted) setState(() => _successMessage = null); }); } else if (result.isDuplicate) { - _showDuplicateDialog(result.duplicateInfo); + _showDuplicateDialog(zongpaiNo, result.duplicateInfo); } else { _showFeedback(result.errorMessage ?? '提交失败', isError: true); } } - void _showDuplicateDialog(Map? info) { + Future _submitBatch(String baseUrl) async { + int successCount = 0; + final failed = []; + final toRemove = []; + + for (final zp in List.of(_zongpaiNos)) { + final result = await _apiService.registerLocation( + baseUrl: baseUrl, + zongpaiNo: zp, + locationCode: _locationCode!, + ); + if (result.success) { + successCount++; + toRemove.add(zp); + } else { + failed.add(zp); + if (result.isDuplicate) { + // 重复上架弹窗中断批量流程 + setState(() { + _zongpaiNos.removeWhere((e) => toRemove.contains(e)); + _isSubmitting = false; + }); + _showDuplicateDialog(zp, result.duplicateInfo); + return; + } + } + } + + if (!mounted) return; + + setState(() { + _zongpaiNos.removeWhere((e) => toRemove.contains(e)); + _isSubmitting = false; + }); + + if (failed.isEmpty) { + _showFeedback('批量上架成功($successCount 条)', isError: false); + } else { + _showFeedback('成功 $successCount 条,失败 ${failed.length} 条', isError: true); + } + } + + void _showDuplicateDialog(String zongpaiNo, Map? info) { showDialog( context: context, barrierDismissible: false, @@ -143,7 +216,7 @@ class _RegistrationPageState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('总排号:${_zongpaiNo ?? ""}'), + Text('总排号:$zongpaiNo'), const SizedBox(height: 4), Text('已登记货位:${info?["location_code"] ?? "未知"}'), const SizedBox(height: 4), @@ -168,7 +241,15 @@ class _RegistrationPageState extends State { _showFeedback('请先扫描货位号', isError: true); return; } - setState(() => _isLocked = value); + setState(() { + _isLocked = value; + if (!value) { + // 退出锁定模式,只保留最后一条总排号 + if (_zongpaiNos.length > 1) { + _zongpaiNos.removeRange(0, _zongpaiNos.length - 1); + } + } + }); } String _locationLabel(CodeType? type) { @@ -232,42 +313,7 @@ class _RegistrationPageState extends State { padding: const EdgeInsets.all(16), child: Column( children: [ - // Zongpai number field - const Align( - alignment: Alignment.centerLeft, - child: Text('总排号', - style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w500)), - ), - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 14), - decoration: BoxDecoration( - border: Border.all( - color: _zongpaiNo != null - ? Colors.green - : Colors.grey.shade400, - width: _zongpaiNo != null ? 2 : 1, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _zongpaiNo ?? '', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: _zongpaiNo != null - ? Colors.black87 - : Colors.grey, - ), - ), - ), - - const SizedBox(height: 20), - - // Location field with type label + // ---- 目标货位 (上方) ---- Row( children: [ const Text('目标货位', @@ -330,7 +376,151 @@ class _RegistrationPageState extends State { ), ), - const SizedBox(height: 28), + const SizedBox(height: 16), + + // ---- 总排号 (下方) ---- + Row( + children: [ + const Text('总排号', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w500)), + if (_isLocked && _zongpaiNos.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${_zongpaiNos.length} 条', + style: const TextStyle( + fontSize: 11, + color: Colors.blue, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 6), + + if (_isLocked) ...[ + // 锁定模式:列表显示多条总排号 + Expanded( + child: _zongpaiNos.isEmpty + ? Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.shade400), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '', + style: TextStyle( + fontSize: 20, color: Colors.grey), + ), + ) + : ListView.separated( + itemCount: _zongpaiNos.length, + separatorBuilder: (_, __) => + const SizedBox(height: 6), + itemBuilder: (context, index) { + return Dismissible( + key: ValueKey( + '${_zongpaiNos[index]}-$index'), + direction: + DismissDirection.endToStart, + onDismissed: (_) => + _removeZongpai(index), + background: Container( + alignment: + Alignment.centerRight, + padding: const EdgeInsets.only( + right: 16), + decoration: BoxDecoration( + color: Colors.red.shade100, + borderRadius: + BorderRadius.circular(8), + ), + child: const Icon(Icons.delete, + color: Colors.red), + ), + child: Container( + padding: + const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12), + decoration: BoxDecoration( + border: Border.all( + color: Colors.green, + width: 2), + borderRadius: + BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + _zongpaiNos[index], + style: const TextStyle( + fontSize: 20, + fontWeight: + FontWeight.bold, + color: Colors.black87, + ), + ), + ), + IconButton( + icon: const Icon( + Icons.close, + size: 20), + onPressed: () => + _removeZongpai(index), + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(), + ), + ], + ), + ), + ); + }, + ), + ), + ] else ...[ + // 单次模式:单个显示 + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 14), + decoration: BoxDecoration( + border: Border.all( + color: _zongpaiNos.isNotEmpty + ? Colors.green + : Colors.grey.shade400, + width: _zongpaiNos.isNotEmpty ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _zongpaiNos.isNotEmpty ? _zongpaiNos.first : '', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: _zongpaiNos.isNotEmpty + ? Colors.black87 + : Colors.grey, + ), + ), + ), + ], + + const SizedBox(height: 16), // Submit button SizedBox( @@ -358,8 +548,12 @@ class _RegistrationPageState extends State { color: Colors.white, ), ) - : const Text('确 认 上 架', - style: TextStyle(fontSize: 18)), + : Text( + _isLocked && _zongpaiNos.length > 1 + ? '批量上架(${_zongpaiNos.length} 条)' + : '确 认 上 架', + style: const TextStyle(fontSize: 18), + ), ), ), ], diff --git a/lib/services/code_parser.dart b/lib/services/code_parser.dart index 9dcd9a6..c4dc651 100644 --- a/lib/services/code_parser.dart +++ b/lib/services/code_parser.dart @@ -11,17 +11,19 @@ class CodeParser { ); static ParseResult parse(String code) { - if (code.isEmpty) { + final trimmed = code.trim(); + if (trimmed.isEmpty) { return ParseResult(type: CodeType.invalid, value: code); } - if (_transitRegex.hasMatch(code)) { - return ParseResult(type: CodeType.locationTransit, value: code); + final normalized = trimmed.toUpperCase(); + if (_transitRegex.hasMatch(normalized)) { + return ParseResult(type: CodeType.locationTransit, value: normalized); } - if (_zongpaiRegex.hasMatch(code)) { - return ParseResult(type: CodeType.zongpaiNo, value: code); + if (_zongpaiRegex.hasMatch(normalized)) { + return ParseResult(type: CodeType.zongpaiNo, value: normalized); } - if (_locationRegex.hasMatch(code)) { - return ParseResult(type: CodeType.locationNormal, value: code); + if (_locationRegex.hasMatch(normalized)) { + return ParseResult(type: CodeType.locationNormal, value: normalized); } return ParseResult(type: CodeType.invalid, value: code); } diff --git a/test/services/code_parser_test.dart b/test/services/code_parser_test.dart index bda585a..12c698d 100644 --- a/test/services/code_parser_test.dart +++ b/test/services/code_parser_test.dart @@ -39,8 +39,15 @@ void main() { final result = CodeParser.parse('TRANS-01'); expect(result.type, CodeType.locationTransit); }); - test('rejects lowercase location code', () { - expect(CodeParser.parse('a01-02-03').type, CodeType.invalid); + test('normalizes lowercase zongpaiNo to uppercase', () { + final result = CodeParser.parse('26b1'); + expect(result.type, CodeType.zongpaiNo); + expect(result.value, '26B1'); + }); + test('normalizes lowercase location code to uppercase', () { + final result = CodeParser.parse('a01-02-03'); + expect(result.type, CodeType.locationNormal); + expect(result.value, 'A01-02-03'); }); test('rejects invalid code', () { expect(CodeParser.parse('HELLO123').type, CodeType.invalid);