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:
@@ -16,7 +16,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
final _scannerService = ScannerService();
|
||||
final _apiService = ApiService();
|
||||
|
||||
String? _zongpaiNo;
|
||||
final _zongpaiNos = <String>[];
|
||||
String? _locationCode;
|
||||
CodeType? _locationType;
|
||||
bool _isLocked = false;
|
||||
@@ -37,7 +37,22 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
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<RegistrationPage> {
|
||||
});
|
||||
}
|
||||
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<RegistrationPage> {
|
||||
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<RegistrationPage> {
|
||||
}
|
||||
|
||||
bool get _canSubmit =>
|
||||
_zongpaiNo != null && _locationCode != null && !_isSubmitting;
|
||||
_zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting;
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_canSubmit) return;
|
||||
@@ -95,9 +116,19 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
|
||||
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(
|
||||
baseUrl: baseUrl,
|
||||
zongpaiNo: _zongpaiNo!,
|
||||
zongpaiNo: zongpaiNo,
|
||||
locationCode: _locationCode!,
|
||||
);
|
||||
|
||||
@@ -107,7 +138,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
|
||||
if (result.success) {
|
||||
setState(() {
|
||||
_zongpaiNo = null;
|
||||
_zongpaiNos.remove(zongpaiNo);
|
||||
if (!_isLocked) {
|
||||
_locationCode = null;
|
||||
_locationType = null;
|
||||
@@ -120,13 +151,55 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
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<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(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -143,7 +216,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
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<RegistrationPage> {
|
||||
_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<RegistrationPage> {
|
||||
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<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
|
||||
SizedBox(
|
||||
@@ -358,8 +548,12 @@ class _RegistrationPageState extends State<RegistrationPage> {
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('确 认 上 架',
|
||||
style: TextStyle(fontSize: 18)),
|
||||
: Text(
|
||||
_isLocked && _zongpaiNos.length > 1
|
||||
? '批量上架(${_zongpaiNos.length} 条)'
|
||||
: '确 认 上 架',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user