import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pad_scanner/services/app_config_service.dart'; import 'package:pad_scanner/services/scanner_service.dart'; import 'package:pad_scanner/services/code_parser.dart'; import 'package:pad_scanner/services/api_service.dart'; import 'package:pad_scanner/services/feedback_service.dart'; import 'package:pad_scanner/services/registration_linking.dart'; import 'package:vibration/vibration.dart'; import 'package:pad_scanner/pages/boxing_page.dart'; import 'package:pad_scanner/widgets/status_bar.dart'; class RegistrationPage extends StatefulWidget { const RegistrationPage({super.key}); @override State createState() => _RegistrationPageState(); } class _RegistrationPageState extends State { final _scannerService = ScannerService(); final _apiService = ApiService(); final _feedbackService = FeedbackService(); final _focusNode = FocusNode(); StreamSubscription? _scanSubscription; final _zongpaiNos = []; String? _locationCode; CodeType? _locationType; bool _isLocked = false; bool _isSubmitting = false; StatusDotColor _statusDot = StatusDotColor.blue; String _statusText = '等待扫描总排号或货位号…'; String? _statusOverrideText; StatusDotColor? _statusOverrideDot; bool _overviewLoading = false; bool _overviewNotFound = false; String? _overviewError; String? _overviewZongpaiNo; PaichaOverviewResult? _overview; int _overviewRequestId = 0; /// Whether a cross-paicha warning dialog is currently showing. bool _crossPaichaPending = false; @override void initState() { super.initState(); _startScanListening(); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); } @override void dispose() { _scanSubscription?.cancel(); _focusNode.dispose(); _feedbackService.dispose(); super.dispose(); } void _startScanListening() { _scanSubscription ??= _scannerService.scanResults.listen(_onScan); } Future _stopScanListening() async { final subscription = _scanSubscription; _scanSubscription = null; await subscription?.cancel(); } void _restartScanListening() { _scanSubscription?.cancel(); _scanSubscription = null; _startScanListening(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _focusNode.requestFocus(); } }); } void _onKeyEvent(KeyEvent event) { if (event is! KeyDownEvent) return; if (_isLockToggleKey(event)) { _toggleLock(!_isLocked); return; } if (event.logicalKey == LogicalKeyboardKey.enter) { _submit(); } } bool _isLockToggleKey(KeyEvent event) { final key = event.logicalKey; return key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonRight1; } void _onScan(ScanResult result) { final parsed = CodeParser.parse(result.barcode); switch (parsed.type) { case CodeType.zongpaiNo: _handleZongpaiScan(parsed.value); case CodeType.locationNormal: case CodeType.locationTransit: if (!_isLocked) { _feedbackService.trigger(FeedbackEvent.scanValid); setState(() { _locationCode = parsed.value; _locationType = parsed.type; _clearStatusOverride(); }); } else {} case CodeType.invalid: _feedbackService.trigger(FeedbackEvent.scanInvalid); _showStatusOverride( '无效码:${result.barcode}', StatusDotColor.red, const Duration(seconds: 2), ); } } void _handleZongpaiScan(String zongpaiNo) { // If cross-paicha dialog is showing, just vibrate and discard if (_crossPaichaPending) { _triggerDoubleVibration(); return; } // In locked mode with existing data, check for cross-paicha scan if (_isLocked && _zongpaiNos.isNotEmpty && _overview?.success == true && !_overview!.items.any((item) => item.zongpaiNo == zongpaiNo)) { _crossPaichaPending = true; _triggerDoubleVibration(); _showCrossPaichaDialog(zongpaiNo); return; } final shouldRefresh = _overviewZongpaiNo != zongpaiNo; _feedbackService.trigger(FeedbackEvent.scanValid); setState(() { if (_isLocked) { if (!_zongpaiNos.contains(zongpaiNo)) { _zongpaiNos.add(zongpaiNo); } } else { if (_zongpaiNos.isEmpty) { _zongpaiNos.add(zongpaiNo); } else { _zongpaiNos[0] = zongpaiNo; } } _clearStatusOverride(); }); if (shouldRefresh) { _loadOverview(zongpaiNo); } } void _triggerDoubleVibration() { Vibration.vibrate(pattern: [0, 200, 100, 200]); } void _removeZongpai(String zongpaiNo) { setState(() { _zongpaiNos.remove(zongpaiNo); }); } void _clearStatusOverride() { _statusOverrideText = null; _statusOverrideDot = null; } void _showStatusOverride(String text, StatusDotColor dot, Duration duration) { setState(() { _statusOverrideText = text; _statusOverrideDot = dot; }); Future.delayed(duration, () { if (mounted) setState(() => _clearStatusOverride()); }); } void _updateBaseStatus() { if (_isSubmitting) { _statusDot = StatusDotColor.orange; _statusText = '正在提交…'; return; } if (_isLocked && _locationCode != null && _zongpaiNos.isEmpty) { _statusDot = StatusDotColor.blue; _statusText = '货位已锁定,请扫描下一张执行卡'; return; } final hasZongpai = _zongpaiNos.isNotEmpty; final hasLocation = _locationCode != null; if (hasZongpai && hasLocation) { _statusDot = StatusDotColor.blue; _statusText = '请确认信息并提交'; } else if (hasZongpai) { _statusDot = StatusDotColor.blue; _statusText = '请扫描目标货位号'; } else if (hasLocation) { _statusDot = StatusDotColor.blue; _statusText = '请扫描执行卡'; } else { _statusDot = StatusDotColor.blue; _statusText = '等待扫描总排号或货位号…'; } } bool get _canSubmit => _zongpaiNos.isNotEmpty && _locationCode != null && !_isSubmitting; bool get _isTransitTarget => isTransitTarget(_locationType, _locationCode); /// Find the first scanned item that is already on shelf (for batch submit blocking). PaichaOverviewItem? _findOnShelfInScanned() { if (_overview?.success != true) return null; final scannedSet = _zongpaiNos.toSet(); for (final item in _overview!.items) { if (scannedSet.contains(item.zongpaiNo) && item.status == 'on_shelf') { return item; } } return null; } Future _baseUrl() async { final configService = AppConfigService(); final baseUrl = await configService.getString('api_url') ?? ''; if (baseUrl.isEmpty) { return null; } return baseUrl; } Future _loadOverview(String zongpaiNo, {bool force = false}) async { if (!force && _overviewZongpaiNo == zongpaiNo && _overview != null) { return; } final requestId = ++_overviewRequestId; setState(() { _overviewZongpaiNo = zongpaiNo; _overviewLoading = true; _overviewNotFound = false; _overviewError = null; }); final baseUrl = await _baseUrl(); if (!mounted || requestId != _overviewRequestId) return; if (baseUrl == null) { setState(() { _overviewLoading = false; _overviewError = '未配置 API 地址,请前往设置'; }); return; } final result = await _apiService.fetchPaichaOverview( baseUrl: baseUrl, zongpaiNo: zongpaiNo, ); if (!mounted || requestId != _overviewRequestId) return; setState(() { _overviewLoading = false; if (result.success) { _overview = result; _overviewNotFound = false; _overviewError = null; } else if (result.notFound) { _overview = null; _overviewNotFound = true; _overviewError = null; } else { _overviewError = result.errorMessage ?? '加载失败,点击重试'; } }); } Future _refreshCurrentOverview() async { final zongpaiNo = _overviewZongpaiNo; if (zongpaiNo == null) return; await _loadOverview(zongpaiNo, force: true); } Future _submit() async { if (!_canSubmit) return; final baseUrl = await _baseUrl(); if (baseUrl == null) { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( '未配置 API 地址,请前往设置', StatusDotColor.red, const Duration(seconds: 2), ); return; } setState(() => _isSubmitting = true); if (_isLocked && _zongpaiNos.length > 1) { if (_isTransitTarget && !await _ensureBatchSamePaicha(baseUrl)) { if (!mounted) return; setState(() => _isSubmitting = false); _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( batchPaichaMismatchMessage, StatusDotColor.red, const Duration(seconds: 2), ); return; } // Pre-check: block entire batch if any item is already on shelf (normal shelf) if (!_isTransitTarget) { final onShelfItem = _findOnShelfInScanned(); if (onShelfItem != null) { setState(() => _isSubmitting = false); _feedbackService.trigger(FeedbackEvent.duplicateError); _showDuplicateDialog( onShelfItem.zongpaiNo, {'location_code': onShelfItem.locationCode ?? '未知'}, ); return; } } await _submitBatch(baseUrl); } else { await _submitOne(baseUrl, _zongpaiNos.first); } } Future _ensureBatchSamePaicha(String baseUrl) async { final paichaByCode = {}; final cachedOverview = _overview; if (cachedOverview?.success == true && cachedOverview!.paichaNo != null) { for (final item in cachedOverview.items) { paichaByCode[item.zongpaiNo] = cachedOverview.paichaNo!; } } for (final zongpaiNo in _zongpaiNos) { if (paichaByCode.containsKey(zongpaiNo)) continue; final result = await _apiService.fetchPaichaOverview( baseUrl: baseUrl, zongpaiNo: zongpaiNo, ); if (!result.success || result.paichaNo == null) { return false; } for (final item in result.items) { paichaByCode[item.zongpaiNo] = result.paichaNo!; } } return allSamePaicha(_zongpaiNos.map((code) => paichaByCode[code])); } Future _submitOne(String baseUrl, String zongpaiNo) async { final result = await _apiService.registerLocation( baseUrl: baseUrl, zongpaiNo: zongpaiNo, locationCode: _locationCode!, ); if (!mounted) return; setState(() => _isSubmitting = false); if (result.success) { _feedbackService.trigger(FeedbackEvent.submitSuccess); if (_isTransitTarget) { setState(() => _isSubmitting = false); await _navigateToBoxing( mode: BoxingMode.singleCode, codes: [zongpaiNo], codesToClear: [zongpaiNo], clearLocation: !_isLocked, ); return; } setState(() { _zongpaiNos.remove(zongpaiNo); if (!_isLocked) { _locationCode = null; _locationType = null; } }); final msg = result.isOffShelfSuccess ? '下架成功' : (_isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功'); _showStatusOverride( msg, StatusDotColor.green, const Duration(milliseconds: 1500), ); await _refreshCurrentOverview(); } else if (result.isDuplicate) { _feedbackService.trigger(FeedbackEvent.duplicateError); _showDuplicateDialog(zongpaiNo, result.duplicateInfo); } else if (result.isAlreadyOffShelf) { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架', StatusDotColor.red, const Duration(seconds: 2), ); } else { final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; _feedbackService.trigger( isNetwork ? FeedbackEvent.networkError : FeedbackEvent.submitFailure, ); _showStatusOverride( result.errorMessage ?? '提交失败', isNetwork ? StatusDotColor.yellow : StatusDotColor.red, const Duration(seconds: 2), ); } } Future _submitBatch(String baseUrl) async { int successCount = 0; int offShelfCount = 0; final failed = []; final toRemove = []; final transitCodes = []; for (final zongpaiNo in List.of(_zongpaiNos)) { final result = await _apiService.registerLocation( baseUrl: baseUrl, zongpaiNo: zongpaiNo, locationCode: _locationCode!, ); if (result.success) { successCount++; if (result.isOffShelfSuccess) { offShelfCount++; } toRemove.add(zongpaiNo); if (_isTransitTarget) { transitCodes.add(zongpaiNo); } } else { failed.add(zongpaiNo); if (result.isDuplicate) { if (_isTransitTarget && transitCodes.isNotEmpty) { setState(() { _zongpaiNos.removeWhere((item) => toRemove.contains(item)); _isSubmitting = false; }); await _navigateToBoxing( mode: BoxingMode.multiCode, codes: transitCodes, codesToClear: toRemove, clearLocation: false, returnMessage: '成功 $successCount 条,失败 ${failed.length} 条', ); return; } setState(() { _zongpaiNos.removeWhere((item) => toRemove.contains(item)); _isSubmitting = false; }); _feedbackService.trigger(FeedbackEvent.duplicateError); _showDuplicateDialog(zongpaiNo, result.duplicateInfo); return; } if (result.isAlreadyOffShelf) { if (_isTransitTarget && transitCodes.isNotEmpty) { setState(() { _zongpaiNos.removeWhere((item) => toRemove.contains(item)); _isSubmitting = false; }); await _navigateToBoxing( mode: BoxingMode.multiCode, codes: transitCodes, codesToClear: toRemove, clearLocation: false, returnMessage: '成功 $successCount 条,失败 ${failed.length} 条', ); return; } setState(() { _zongpaiNos.removeWhere((item) => toRemove.contains(item)); _isSubmitting = false; }); _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架', StatusDotColor.red, const Duration(seconds: 2), ); return; } } } if (!mounted) return; setState(() { _zongpaiNos.removeWhere((item) => toRemove.contains(item)); _isSubmitting = false; }); if (_isTransitTarget && transitCodes.isNotEmpty) { await _navigateToBoxing( mode: BoxingMode.multiCode, codes: transitCodes, codesToClear: toRemove, clearLocation: failed.isEmpty, returnMessage: failed.isEmpty ? null : '成功 $successCount 条,失败 ${failed.length} 条', ); return; } if (failed.isEmpty) { _feedbackService.trigger(FeedbackEvent.submitSuccess); final msg = offShelfCount == successCount ? '批量下架成功($successCount 条)' : '批量上架成功($successCount 条)'; _showStatusOverride( msg, StatusDotColor.green, const Duration(milliseconds: 1500), ); await _refreshCurrentOverview(); } else { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( '成功 $successCount 条,失败 ${failed.length} 条', StatusDotColor.red, const Duration(seconds: 2), ); } } Future _navigateToBoxing({ required BoxingMode mode, required List codes, required List codesToClear, required bool clearLocation, String? returnMessage, }) async { await _refreshCurrentOverview(); if (!mounted) return; final navigator = Navigator.of(context); await _stopScanListening(); await navigator.push( MaterialPageRoute( builder: (_) => BoxingPage( arguments: BoxingPageArguments( initialMode: mode, autoScanCodes: codes, ), ), ), ); if (!mounted) return; await Future.delayed(const Duration(milliseconds: 120)); if (!mounted) return; _restartScanListening(); setState(() { _zongpaiNos.removeWhere((item) => codesToClear.contains(item)); if (clearLocation) { _locationCode = null; _locationType = null; } }); await _refreshCurrentOverview(); if (!mounted) return; if (returnMessage != null) { _showStatusOverride( returnMessage, StatusDotColor.red, const Duration(seconds: 2), ); } } void _showDuplicateDialog(String zongpaiNo, Map? info) { showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( backgroundColor: Colors.red.shade50, title: const Row( children: [ Icon(Icons.warning, color: Colors.red), SizedBox(width: 8), Text('重复上架', style: TextStyle(color: Colors.red)), ], ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('总排号:$zongpaiNo'), const SizedBox(height: 4), Text('已登记货位:${info?["location_code"] ?? "未知"}'), const SizedBox(height: 4), Text('登记时间:${info?["registered_at"] ?? "未知"}'), const SizedBox(height: 12), const Text( '请核查实物,确认是否操作错误。', style: TextStyle(fontWeight: FontWeight.bold), ), ], ), actions: [ TextButton( onPressed: () { _feedbackService.stopAlert(); Navigator.pop(ctx); }, child: const Text('关闭'), ), ], ), ); } void _showCrossPaichaDialog(String newZongpaiNo) { final currentPaicha = _overview?.paichaNo ?? '--'; final dialogFocus = FocusNode(); var dismissed = false; var selectedIndex = 0; // 0 = "否" (default), 1 = "是" void dismissAsNo() { if (dismissed) return; dismissed = true; Navigator.of(context).pop(); setState(() { _crossPaichaPending = false; }); } void confirmSwitch() { if (dismissed) return; dismissed = true; Navigator.of(context).pop(); _switchToNewPaicha(newZongpaiNo); } showDialog( context: context, barrierDismissible: false, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) { return KeyboardListener( focusNode: dialogFocus, onKeyEvent: (event) { if (event is! KeyDownEvent) return; if (event.logicalKey == LogicalKeyboardKey.arrowUp) { setDialogState(() => selectedIndex = 0); } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { setDialogState(() => selectedIndex = 1); } else if (event.logicalKey == LogicalKeyboardKey.enter) { selectedIndex == 0 ? dismissAsNo() : confirmSwitch(); } else if (event.logicalKey == LogicalKeyboardKey.escape) { dismissAsNo(); } }, child: PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) dismissAsNo(); }, child: AlertDialog( backgroundColor: Colors.orange.shade50, title: Row( children: [ Icon(Icons.warning_amber, color: Colors.orange.shade800), const SizedBox(width: 8), Text( '跨排产号扫描', style: TextStyle(color: Colors.orange.shade900), ), ], ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( '总排号 $newZongpaiNo 不属于当前排产号($currentPaicha),' '是否切换到新的排产号?\n\n' '选择「是」将放弃当前已扫描的所有数据。', style: const TextStyle(fontSize: 14), ), const SizedBox(height: 16), _dialogOptionBtn( label: '否', selected: selectedIndex == 0, onTap: dismissAsNo, ), const SizedBox(height: 8), _dialogOptionBtn( label: '是,切换排产号', selected: selectedIndex == 1, onTap: confirmSwitch, ), ], ), ), ), ); }, ), ); WidgetsBinding.instance.addPostFrameCallback((_) { dialogFocus.requestFocus(); }); } Widget _dialogOptionBtn({ required String label, required bool selected, required VoidCallback onTap, }) { return SizedBox( width: double.infinity, height: 40, child: ElevatedButton( onPressed: onTap, style: ElevatedButton.styleFrom( backgroundColor: selected ? Colors.blue.shade700 : Colors.grey.shade200, foregroundColor: selected ? Colors.white : Colors.black87, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: Text( label, style: TextStyle( fontWeight: selected ? FontWeight.w800 : FontWeight.w600, ), ), ), ); } void _switchToNewPaicha(String newZongpaiNo) { _feedbackService.trigger(FeedbackEvent.paichanSwitch); setState(() { _crossPaichaPending = false; _zongpaiNos.clear(); _zongpaiNos.add(newZongpaiNo); _overview = null; _overviewNotFound = false; _overviewError = null; _clearStatusOverride(); }); _loadOverview(newZongpaiNo); } void _toggleLock(bool value) { if (value && _locationCode == null) { _feedbackService.trigger(FeedbackEvent.submitFailure); _showStatusOverride( '请先扫描货位号', StatusDotColor.red, const Duration(seconds: 2), ); return; } setState(() { _isLocked = value; if (!value && _zongpaiNos.length > 1) { _zongpaiNos.removeRange(0, _zongpaiNos.length - 1); } }); } String _locationLabel(CodeType? type) { if (type == CodeType.locationTransit) return '转运区域'; return '普通货架'; } Color _locationLabelColor(CodeType? type) { if (type == CodeType.locationTransit) return Colors.orange; return Colors.blue; } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; _updateBaseStatus(); final effectiveDot = _statusOverrideDot ?? _statusDot; final effectiveText = _statusOverrideText ?? _statusText; return KeyboardListener( focusNode: _focusNode, onKeyEvent: _onKeyEvent, child: Scaffold( appBar: AppBar( title: Row( children: [ const Text('上架登记'), const SizedBox(width: 12), Expanded(child: _buildLockHeader()), ], ), titleSpacing: 12, ), body: Column( children: [ _buildPaichaHeader(), if (_overview?.success == true) _buildTableHead(), Expanded(child: _buildListBody()), _buildBottomBar(colorScheme), StatusBar(dotColor: effectiveDot, text: effectiveText), ], ), ), ); } Widget _buildLockHeader() { final backgroundColor = _isLocked ? Colors.orange.shade700 : Colors.blue.shade700; final label = _isLocked ? '货位锁定' : '未锁定'; return Material( borderRadius: BorderRadius.circular(8), color: backgroundColor, child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () => _toggleLock(!_isLocked), child: Container( width: double.infinity, height: 36, padding: const EdgeInsets.symmetric(horizontal: 10), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.swap_horiz, color: Colors.white, size: 18), const SizedBox(width: 6), Flexible( child: FittedBox( fit: BoxFit.scaleDown, child: Text( label, maxLines: 1, style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w800, ), ), ), ), const SizedBox(width: 8), Text( 'P2', style: TextStyle( color: Colors.white.withValues(alpha: 0.88), fontSize: 12, fontWeight: FontWeight.w700, ), ), ], ), ), ), ); } Widget _buildLocationChip() { final color = _locationLabelColor(_locationType); return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: Text( _locationLabel(_locationType), style: TextStyle( fontSize: 11, color: color, fontWeight: FontWeight.bold, ), ), ); } // === Paicha Header === Widget _buildPaichaHeader() { final overview = _overview; final hasData = overview?.success == true; final hasLoc = _locationCode != null; int totalCount = 0, shelvedCount = 0, transferredCount = 0; if (hasData && overview != null) { totalCount = overview.totalCount; for (final item in overview.items) { switch (item.status) { case 'on_shelf': shelvedCount++; case 'transferred': transferredCount++; } } } return Container( padding: const EdgeInsets.fromLTRB(12, 8, 12, 6), decoration: BoxDecoration( color: Colors.grey.shade50, border: Border(bottom: BorderSide(color: Colors.grey.shade300)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( hasData ? (overview!.paichaNo ?? '--') : '--', style: TextStyle( fontSize: 24, fontWeight: FontWeight.w800, color: hasData ? Colors.black87 : Colors.grey.shade400, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ if (hasLoc) Row( mainAxisSize: MainAxisSize.min, children: [ Text( _locationCode!, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: Colors.black54, ), ), const SizedBox(width: 5), _buildLocationChip(), ], ), Row( mainAxisSize: MainAxisSize.min, children: [ _buildStatTag( '共 $totalCount', const Color(0xFFE3F2FD), const Color(0xFF1565C0), hasData, ), _buildStatTag( '上架 $shelvedCount', const Color(0xFFE8F5E9), const Color(0xFF2E7D32), hasData, ), _buildStatTag( '转运 $transferredCount', const Color(0xFFFFF3E0), const Color(0xFFE65100), hasData, ), ], ), ], ), ), ], ), ); } Widget _buildStatTag(String text, Color bg, Color fg, bool hasData) { return Container( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), margin: const EdgeInsets.only(left: 5), decoration: BoxDecoration( color: hasData ? bg : const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10), ), child: Text( text, style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: hasData ? fg : Colors.grey.shade400, ), ), ); } // === Table Head === Widget _buildTableHead() { return Container( height: 28, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: Colors.grey.shade200, border: Border(bottom: BorderSide(color: Colors.grey.shade400)), ), child: const Row( children: [ Expanded(flex: 22, child: Text('总排号', style: _headerStyle)), Expanded(flex: 18, child: Text('工令号', style: _headerStyle)), Expanded( flex: 12, child: Text( '数量', textAlign: TextAlign.center, style: _headerStyle, ), ), Expanded( flex: 28, child: Text( '货位号', textAlign: TextAlign.right, style: _headerStyle, ), ), ], ), ); } // === List Body === Widget _buildListBody() { if (_overviewZongpaiNo == null) { return _buildListMessage('扫描总排号后自动显示排产号上架情况'); } if (_overviewLoading && _overview == null) { return _buildListMessage('正在加载排产号上架情况…'); } if (_overviewNotFound) { return _buildListMessage('暂无排产信息'); } if (_overviewError != null && _overview == null) { return _buildListError(_overviewError!); } final overview = _overview; if (overview == null || !overview.success) { return _buildListMessage('扫描总排号后自动显示排产号上架情况'); } final scannedSet = _zongpaiNos.toSet(); final scanned = []; final unscanned = []; for (final item in overview.items) { if (scannedSet.contains(item.zongpaiNo)) { scanned.add(item); } else { unscanned.add(item); } } final hasDivider = scanned.isNotEmpty && unscanned.isNotEmpty; return Column( children: [ if (_overviewError != null) Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), color: Colors.red.shade50, child: Row( children: [ Expanded( child: Text( _overviewError!, style: const TextStyle(fontSize: 12, color: Colors.red), ), ), TextButton( onPressed: _overviewZongpaiNo == null ? null : () => _loadOverview(_overviewZongpaiNo!, force: true), child: const Text('重试'), ), ], ), ), Expanded( child: ListView.builder( itemCount: scanned.length + (hasDivider ? 1 : 0) + unscanned.length, itemBuilder: (context, index) { if (index < scanned.length) { return _buildListRow(scanned[index], isScanned: true); } if (index == scanned.length && hasDivider) { return _buildSectionDivider(unscanned.length); } final unscannedIdx = index - scanned.length - (hasDivider ? 1 : 0); return _buildListRow(unscanned[unscannedIdx], isScanned: false); }, ), ), ], ); } Widget _buildSectionDivider(int count) { return Container( height: 24, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: Colors.grey.shade200)), ), child: Row( children: [ const Expanded(child: Divider(height: 0)), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( '以下 $count 项未操作', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, color: Colors.grey.shade500, ), ), ), const Expanded(child: Divider(height: 0)), ], ), ); } Widget _buildListRow(PaichaOverviewItem item, {required bool isScanned}) { final rowStyle = TextStyle( fontSize: 12, fontWeight: isScanned ? FontWeight.w700 : FontWeight.w500, color: Colors.black87, ); final Color barColor; final Color bgColor; if (isScanned) { final alreadyOnShelf = item.status == 'on_shelf' && !_isTransitTarget; if (alreadyOnShelf) { barColor = Colors.red; bgColor = Colors.red.shade50; } else { barColor = const Color(0xFF43A047); bgColor = const Color(0xFFF1F8E9); } } else { barColor = _barColor(item.status); bgColor = _rowBgColor(item.status); } return Container( constraints: const BoxConstraints(minHeight: 36), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: bgColor, border: Border( left: BorderSide(color: barColor, width: 3), bottom: BorderSide(color: Colors.grey.shade200), ), ), child: Row( children: [ Expanded( flex: 22, child: Text( item.zongpaiNo, overflow: TextOverflow.ellipsis, style: rowStyle, ), ), Expanded( flex: 18, child: Text( item.workOrderNo ?? '--', overflow: TextOverflow.ellipsis, style: rowStyle, ), ), Expanded( flex: 12, child: Text( item.quantity.toString(), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, style: rowStyle, ), ), Expanded( flex: 28, child: Text( item.locationCode ?? '\u2014', textAlign: TextAlign.right, overflow: TextOverflow.ellipsis, style: rowStyle, ), ), if (isScanned && _isLocked) ...[ const SizedBox(width: 6), SizedBox( width: 28, height: 28, child: IconButton( icon: Icon(Icons.delete_outline, size: 16), color: Colors.red, onPressed: () => _removeZongpai(item.zongpaiNo), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), ), ), ], ], ), ); } Color _barColor(String status) { switch (status) { case 'on_shelf': return const Color(0xFF2196F3); case 'transferred': return const Color(0xFFFF9800); default: return Colors.grey.shade400; } } Color _rowBgColor(String status) { switch (status) { case 'on_shelf': return const Color(0xFFE3F2FD); case 'transferred': return const Color(0xFFFFF3E0); default: return const Color(0xFFF5F5F5); } } Widget _buildListMessage(String text) { return Center( child: Text( text, textAlign: TextAlign.center, style: TextStyle(fontSize: 13, color: Colors.grey.shade600), ), ); } Widget _buildListError(String text) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: Colors.red), ), const SizedBox(height: 6), TextButton( onPressed: _overviewZongpaiNo == null ? null : () => _loadOverview(_overviewZongpaiNo!, force: true), child: const Text('重试'), ), ], ), ); } // === Bottom Bar === Widget _buildBottomBar(ColorScheme colorScheme) { return Container( decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.grey.shade300)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: SizedBox( width: double.infinity, height: 34, child: ElevatedButton( onPressed: _canSubmit ? _submit : null, style: ElevatedButton.styleFrom( backgroundColor: registrationSubmitColor( canSubmit: _canSubmit, isTransitTarget: _isTransitTarget, primaryColor: colorScheme.primary, ), foregroundColor: _canSubmit ? Colors.white : Colors.grey.shade600, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), ), ), child: _isSubmitting ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : Text( registrationSubmitLabel( isTransitTarget: _isTransitTarget, isLocked: _isLocked, zongpaiCount: _zongpaiNos.length, ), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w700, ), ), ), ), ); } } const _headerStyle = TextStyle(fontSize: 10, fontWeight: FontWeight.w700);