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 _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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user