feat: improve scan UX — case normalization, lock-mode list, layout swap

- CodeParser: normalize scanned barcode to uppercase and trim whitespace
  to handle scanner hardware outputting lowercase or trailing characters
- RegistrationPage: swap layout so location field is above zongpai field
- Lock mode: zongpai display changes to scrollable list supporting
  multiple entries with swipe-to-delete and per-item delete buttons
- Lock mode: batch submit when multiple zongpai numbers are queued
- Show scanned barcode content in invalid code error messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-05-09 17:02:21 +08:00
parent d136dbf947
commit b129ed9714
3 changed files with 263 additions and 60 deletions

View File

@@ -16,7 +16,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
final _scannerService = ScannerService(); final _scannerService = ScannerService();
final _apiService = ApiService(); final _apiService = ApiService();
String? _zongpaiNo; final _zongpaiNos = <String>[];
String? _locationCode; String? _locationCode;
CodeType? _locationType; CodeType? _locationType;
bool _isLocked = false; bool _isLocked = false;
@@ -37,7 +37,22 @@ class _RegistrationPageState extends State<RegistrationPage> {
switch (parsed.type) { switch (parsed.type) {
case CodeType.zongpaiNo: case CodeType.zongpaiNo:
setState(() { 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; _snackbarMessage = null;
_successMessage = null; _successMessage = null;
}); });
@@ -52,10 +67,16 @@ class _RegistrationPageState extends State<RegistrationPage> {
}); });
} }
case CodeType.invalid: 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}) { void _showFeedback(String message, {bool isError = false}) {
setState(() { setState(() {
_snackbarMessage = message; _snackbarMessage = message;
@@ -69,10 +90,10 @@ class _RegistrationPageState extends State<RegistrationPage> {
String get _statusText { String get _statusText {
if (_isSubmitting) return '正在提交…'; if (_isSubmitting) return '正在提交…';
if (_successMessage != null) return _successMessage!; if (_successMessage != null) return _successMessage!;
if (_isLocked && _locationCode != null && _zongpaiNo == null) { if (_isLocked && _locationCode != null && _zongpaiNos.isEmpty) {
return '货位已锁定,请扫描下一张执行卡'; return '货位已锁定,请扫描下一张执行卡';
} }
final hasZ = _zongpaiNo != null; final hasZ = _zongpaiNos.isNotEmpty;
final hasL = _locationCode != null; final hasL = _locationCode != null;
if (hasZ && hasL) return '请确认信息并提交'; if (hasZ && hasL) return '请确认信息并提交';
if (hasZ && !hasL) return '请扫描目标货位号'; if (hasZ && !hasL) return '请扫描目标货位号';
@@ -81,7 +102,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
} }
bool get _canSubmit => bool get _canSubmit =>
_zongpaiNo != null && _locationCode != null && !_isSubmitting; _zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
Future<void> _submit() async { Future<void> _submit() async {
if (!_canSubmit) return; if (!_canSubmit) return;
@@ -95,9 +116,19 @@ class _RegistrationPageState extends State<RegistrationPage> {
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
if (_isLocked && _zongpaiNos.length > 1) {
// 批量提交:逐个提交列表中的总排号
await _submitBatch(baseUrl);
} else {
// 单条提交
await _submitOne(baseUrl, _zongpaiNos.first);
}
}
Future<void> _submitOne(String baseUrl, String zongpaiNo) async {
final result = await _apiService.registerLocation( final result = await _apiService.registerLocation(
baseUrl: baseUrl, baseUrl: baseUrl,
zongpaiNo: _zongpaiNo!, zongpaiNo: zongpaiNo,
locationCode: _locationCode!, locationCode: _locationCode!,
); );
@@ -107,7 +138,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
if (result.success) { if (result.success) {
setState(() { setState(() {
_zongpaiNo = null; _zongpaiNos.remove(zongpaiNo);
if (!_isLocked) { if (!_isLocked) {
_locationCode = null; _locationCode = null;
_locationType = null; _locationType = null;
@@ -120,13 +151,55 @@ class _RegistrationPageState extends State<RegistrationPage> {
if (mounted) setState(() => _successMessage = null); if (mounted) setState(() => _successMessage = null);
}); });
} else if (result.isDuplicate) { } else if (result.isDuplicate) {
_showDuplicateDialog(result.duplicateInfo); _showDuplicateDialog(zongpaiNo, result.duplicateInfo);
} else { } else {
_showFeedback(result.errorMessage ?? '提交失败', isError: true); _showFeedback(result.errorMessage ?? '提交失败', isError: true);
} }
} }
void _showDuplicateDialog(Map<String, dynamic>? info) { Future<void> _submitBatch(String baseUrl) async {
int successCount = 0;
final failed = <String>[];
final toRemove = <String>[];
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<String, dynamic>? info) {
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
@@ -143,7 +216,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('总排号:${_zongpaiNo ?? ""}'), Text('总排号:$zongpaiNo'),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('已登记货位:${info?["location_code"] ?? "未知"}'), Text('已登记货位:${info?["location_code"] ?? "未知"}'),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -168,7 +241,15 @@ class _RegistrationPageState extends State<RegistrationPage> {
_showFeedback('请先扫描货位号', isError: true); _showFeedback('请先扫描货位号', isError: true);
return; return;
} }
setState(() => _isLocked = value); setState(() {
_isLocked = value;
if (!value) {
// 退出锁定模式,只保留最后一条总排号
if (_zongpaiNos.length > 1) {
_zongpaiNos.removeRange(0, _zongpaiNos.length - 1);
}
}
});
} }
String _locationLabel(CodeType? type) { String _locationLabel(CodeType? type) {
@@ -232,42 +313,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ 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( Row(
children: [ children: [
const Text('目标货位', const Text('目标货位',
@@ -330,7 +376,151 @@ class _RegistrationPageState extends State<RegistrationPage> {
), ),
), ),
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 // Submit button
SizedBox( SizedBox(
@@ -358,8 +548,12 @@ class _RegistrationPageState extends State<RegistrationPage> {
color: Colors.white, color: Colors.white,
), ),
) )
: const Text('确 认 上 架', : Text(
style: TextStyle(fontSize: 18)), _isLocked && _zongpaiNos.length > 1
? '批量上架(${_zongpaiNos.length} 条)'
: '确 认 上 架',
style: const TextStyle(fontSize: 18),
),
), ),
), ),
], ],

View File

@@ -11,17 +11,19 @@ class CodeParser {
); );
static ParseResult parse(String code) { static ParseResult parse(String code) {
if (code.isEmpty) { final trimmed = code.trim();
if (trimmed.isEmpty) {
return ParseResult(type: CodeType.invalid, value: code); return ParseResult(type: CodeType.invalid, value: code);
} }
if (_transitRegex.hasMatch(code)) { final normalized = trimmed.toUpperCase();
return ParseResult(type: CodeType.locationTransit, value: code); if (_transitRegex.hasMatch(normalized)) {
return ParseResult(type: CodeType.locationTransit, value: normalized);
} }
if (_zongpaiRegex.hasMatch(code)) { if (_zongpaiRegex.hasMatch(normalized)) {
return ParseResult(type: CodeType.zongpaiNo, value: code); return ParseResult(type: CodeType.zongpaiNo, value: normalized);
} }
if (_locationRegex.hasMatch(code)) { if (_locationRegex.hasMatch(normalized)) {
return ParseResult(type: CodeType.locationNormal, value: code); return ParseResult(type: CodeType.locationNormal, value: normalized);
} }
return ParseResult(type: CodeType.invalid, value: code); return ParseResult(type: CodeType.invalid, value: code);
} }

View File

@@ -39,8 +39,15 @@ void main() {
final result = CodeParser.parse('TRANS-01'); final result = CodeParser.parse('TRANS-01');
expect(result.type, CodeType.locationTransit); expect(result.type, CodeType.locationTransit);
}); });
test('rejects lowercase location code', () { test('normalizes lowercase zongpaiNo to uppercase', () {
expect(CodeParser.parse('a01-02-03').type, CodeType.invalid); 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', () { test('rejects invalid code', () {
expect(CodeParser.parse('HELLO123').type, CodeType.invalid); expect(CodeParser.parse('HELLO123').type, CodeType.invalid);