From a20645671b2d68d4ef0cac146680f384e676dc6e Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 22:25:22 +0800 Subject: [PATCH] chore: release client-v1.0.75 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 出库列表加商品编码精确搜索框 + 移除数据导入入口 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx --- CHANGELOG-client.md | 8 + client/lib/providers/stock_out_provider.dart | 9 + .../repositories/stock_out_repository.dart | 3 + .../lib/screens/settings/settings_screen.dart | 713 +----------------- .../stock_out/stock_out_list_screen.dart | 26 + 5 files changed, 48 insertions(+), 711 deletions(-) diff --git a/CHANGELOG-client.md b/CHANGELOG-client.md index f64deb1..37a6955 100644 --- a/CHANGELOG-client.md +++ b/CHANGELOG-client.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.75] - 2026-06-22 + +### 新功能 +- 出库单列表新增「商品编码」搜索框:输入完整商品编码回车,即可精确查出包含该商品的所有出库单(与原「单号/往来单位」搜索互不影响) + +### 变更 +- 移除「系统设置 → 数据管理」里的数据导入入口:数据导入不再对用户开放 + ## [1.0.74] - 2026-06-22 ### 改进 diff --git a/client/lib/providers/stock_out_provider.dart b/client/lib/providers/stock_out_provider.dart index 105a93d..8379beb 100644 --- a/client/lib/providers/stock_out_provider.dart +++ b/client/lib/providers/stock_out_provider.dart @@ -24,6 +24,7 @@ class StockOutListNotifier extends AsyncNotifier> { String? _startDate; String? _endDate; String _keyword = ''; + String _productCode = ''; PageResult? _cache; @override @@ -46,6 +47,7 @@ class StockOutListNotifier extends AsyncNotifier> { startDate: _startDate, endDate: _endDate, keyword: _keyword.isEmpty ? null : _keyword, + productCode: _productCode.isEmpty ? null : _productCode, page: _page, pageSize: _pageSize, ); @@ -84,6 +86,13 @@ class StockOutListNotifier extends AsyncNotifier> { reload(); } + /// 按商品编码精确反查(独立于 keyword) + void setProductCode(String code) { + _productCode = code; + _page = 1; + reload(); + } + void reload() { state = const AsyncValue.loading(); _fetch().then((result) { diff --git a/client/lib/repositories/stock_out_repository.dart b/client/lib/repositories/stock_out_repository.dart index eb73f06..a5f88c5 100644 --- a/client/lib/repositories/stock_out_repository.dart +++ b/client/lib/repositories/stock_out_repository.dart @@ -14,6 +14,7 @@ class StockOutRepository { String? startDate, String? endDate, String? keyword, + String? productCode, int page = 1, int pageSize = 20, }) async { @@ -25,6 +26,8 @@ class StockOutRepository { if (startDate != null) 'start_date': startDate, if (endDate != null) 'end_date': endDate, if (keyword != null && keyword.isNotEmpty) 'keyword': keyword, + if (productCode != null && productCode.isNotEmpty) + 'product_code': productCode, }; final resp = await _client.get('/stock-out/orders', params: params); return PageResult.fromJson( diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index f4daee1..948becc 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1,17 +1,14 @@ import 'dart:async'; import '../../core/utils/dialog_util.dart'; -import 'package:dio/dio.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/auth/auth_state.dart'; import '../../widgets/write_guard.dart'; import '../../core/config/app_config.dart'; -import '../../core/config/app_constants.dart'; import '../../core/config/app_info.dart'; import '../../core/config/license_copy.dart'; import '../../core/responsive/responsive.dart'; @@ -46,8 +43,8 @@ class _SettingsScreenState extends ConsumerState { @override Widget build(BuildContext context) { return DefaultTabController( - length: 6, - initialIndex: widget.initialTab, + length: 5, + initialIndex: widget.initialTab >= 5 ? 0 : widget.initialTab, child: Column( children: [ Container( @@ -66,7 +63,6 @@ class _SettingsScreenState extends ConsumerState { Tab(text: '编号规则'), Tab(text: '系统参数'), Tab(text: '授权'), - Tab(text: '数据管理'), ], ), ), @@ -79,7 +75,6 @@ class _SettingsScreenState extends ConsumerState { _buildNumberRulesTab(), _buildSystemParamsTab(), _buildLicenseTab(), - _buildImportTab(), ], ), ), @@ -823,31 +818,6 @@ class _SettingsScreenState extends ConsumerState { ); } - // ── 数据导入 Tab ────────────────────────────────────────── - Widget _buildImportTab() { - if (WriteGuard.isReadonly(ref)) { - return const Center( - child: Text('只读账号无导入权限', - style: TextStyle(color: AppTheme.textSecondary)), - ); - } - if (WriteGuard.licenseBlocked(ref)) { - final lic = ref.watch(licenseProvider).valueOrNull; - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - lic == null ? '授权已过期,暂时无法导入' : LicenseCopy.writeBlockedToast(lic), - textAlign: TextAlign.center, - style: const TextStyle(color: AppTheme.textSecondary), - ), - ), - ); - } - final currentUser = ref.watch(authStateProvider).user; - final isSuperAdmin = currentUser?.role == 'superadmin'; - return _BatchImportWidget(isSuperAdmin: isSuperAdmin); - } void _showEditParamDialog( String label, String current, ValueChanged onSave) { @@ -1213,685 +1183,6 @@ class _ParamRow extends StatelessWidget { } } -// ── 批量数据导入 ─────────────────────────────────────────── - -class _ImportSlot { - final String title; - final String endpoint; - final String hint; - PlatformFile? file; - // null = 未运行;true = 成功;false = 失败 - bool? success; - String? error; - int total = 0; - int imported = 0; - int skipped = 0; - int updated = 0; - // 进度 - int uploadPercent = 0; - bool isProcessing = false; - int processingSeconds = 0; - - _ImportSlot(this.title, this.endpoint, this.hint); - - bool get hasResult => success != null; - - void resetProgress() { - uploadPercent = 0; - isProcessing = false; - processingSeconds = 0; - success = null; - error = null; - } -} - -class _BatchImportWidget extends ConsumerStatefulWidget { - final bool isSuperAdmin; - const _BatchImportWidget({required this.isSuperAdmin}); - - @override - ConsumerState<_BatchImportWidget> createState() => _BatchImportWidgetState(); -} - -class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { - bool _loading = false; - String? _lastDir; - OverlayEntry? _importBarrier; - - static const _prefKey = 'import_last_dir'; - - late final List<_ImportSlot> _slots = [ - _ImportSlot('往来单位', '/import/partners', - '格式:编号 | 类型 | 状态 | 名称 | 电话 | 卡号 | 初始金额 | 单位 | 地址 | 备注'), - _ImportSlot('商品名称', '/import/product-names', - '格式:选项编号 | 选项名称 | 备注'), - _ImportSlot('商品系列', '/import/product-series', - '格式:选项编号 | 选项名称 | 备注'), - _ImportSlot('商品规格', '/import/product-specs', - '格式:选项编号 | 选项名称 | 单品数量 | 备注'), - _ImportSlot('库存', '/import/inventory', - '格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'), - _ImportSlot('入库单', '/import/stock-in', - '老系统入库单打印格式:每个文件一张单(含单据号/往来单位/日期/明细)'), - _ImportSlot('出库单', '/import/stock-out', - '老系统出库单打印格式:每个文件一张单(含单据号/往来单位/日期/明细)'), - ]; - - final Set _selectedTables = {}; - bool _clearing = false; - - static const _clearOptions = [ - (key: 'stock_in', label: '入库单'), - (key: 'stock_out', label: '出库单'), - (key: 'inventory', label: '库存管理'), - (key: 'products', label: '商品详情'), - (key: 'partners', label: '往来单位'), - ]; - - @override - void initState() { - super.initState(); - SharedPreferences.getInstance().then((prefs) { - final dir = prefs.getString(_prefKey); - if (dir != null && mounted) setState(() => _lastDir = dir); - }); - } - - Future _pickFile(int index) async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['xls', 'xlsx'], - withData: true, - initialDirectory: kIsWeb ? null : _lastDir, - ); - if (result == null || result.files.isEmpty) return; - - // 保存目录供下次使用(仅非 web 平台) - if (!kIsWeb) { - final path = result.files.first.path; - if (path != null) { - final dir = path.contains('/') ? path.substring(0, path.lastIndexOf('/')) : null; - if (dir != null) { - _lastDir = dir; - SharedPreferences.getInstance().then((p) => p.setString(_prefKey, dir)); - } - } - } - - if (!mounted) return; - setState(() { - _slots[index].file = result.files.first; - _slots[index].success = null; - _slots[index].error = null; - }); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('选择文件失败:$e')), - ); - } - } - - void _showBarrier() { - _importBarrier = OverlayEntry( - builder: (_) => Stack(children: [ - const ModalBarrier(dismissible: false, color: Colors.transparent), - Positioned( - bottom: 24, left: 0, right: 0, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - decoration: BoxDecoration( - color: Colors.black87, - borderRadius: BorderRadius.circular(8), - ), - child: const Row(mainAxisSize: MainAxisSize.min, children: [ - SizedBox(width: 14, height: 14, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), - SizedBox(width: 10), - Text('导入中,请勿切换页面…', - style: TextStyle(color: Colors.white, fontSize: 13)), - ]), - ), - ), - ), - ]), - ); - Overlay.of(context, rootOverlay: true).insert(_importBarrier!); - } - - void _removeBarrier() { - _importBarrier?.remove(); - _importBarrier = null; - } - - Future _runImport() async { - final token = ref.read(authStateProvider).user?.accessToken ?? ''; - if (!_slots.any((s) => s.file != null)) return; - - _showBarrier(); - setState(() { - _loading = true; - for (final s in _slots) { - if (s.file != null) s.resetProgress(); - } - }); - - final dio = Dio(BaseOptions( - baseUrl: AppConfig.apiBaseUrl, - sendTimeout: AppConstants.importSendTimeout, - receiveTimeout: AppConstants.importReceiveTimeout, - )); - dio.options.headers['Authorization'] = 'Bearer $token'; - - for (final slot in _slots) { - if (slot.file == null) continue; - final bytes = slot.file!.bytes; - if (bytes == null) { - if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; }); - continue; - } - - Timer? processingTimer; - bool uploadDone = false; - - try { - final formData = FormData.fromMap({ - 'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name), - }); - final resp = await dio.post( - slot.endpoint, - data: formData, - onSendProgress: (sent, total) { - if (total <= 0 || !mounted) return; - if (sent >= total && !uploadDone) { - uploadDone = true; - setState(() { slot.isProcessing = true; slot.processingSeconds = 0; }); - processingTimer = Timer.periodic(const Duration(seconds: 1), (_) { - if (!mounted) return; - setState(() => slot.processingSeconds++); - }); - } else if (!uploadDone) { - final pct = (sent / total * 100).round().clamp(0, 99); - if (mounted) setState(() => slot.uploadPercent = pct); - } - }, - ); - processingTimer?.cancel(); - final data = (resp.data is Map) ? resp.data as Map : {}; - if (mounted) setState(() { - slot.success = true; - if (data.containsKey('order_no')) { - // 单据类导入(入库单/出库单):每个文件一张单, - // 已存在时后端返回 {skipped: true}(布尔),不能按 int 解析。 - final skippedOne = data['skipped'] == true; - slot.imported = skippedOne ? 0 : 1; - slot.skipped = skippedOne ? 1 : 0; - slot.updated = 0; - slot.total = 1; - } else { - slot.imported = (data['imported'] ?? 0) as int; - slot.skipped = (data['skipped'] ?? 0) as int; - slot.updated = (data['updated'] ?? 0) as int; - slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) as int; - } - slot.isProcessing = false; - }); - } on DioException catch (e) { - processingTimer?.cancel(); - final raw = e.response?.data; - final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误'; - if (mounted) setState(() { - slot.success = false; - slot.error = msg.toString(); - slot.isProcessing = false; - }); - } catch (e) { - processingTimer?.cancel(); - if (mounted) setState(() { - slot.success = false; - slot.error = e.toString(); - slot.isProcessing = false; - }); - } - } - - if (mounted) { - _removeBarrier(); - setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - final hasAnyFile = _slots.any((s) => s.file != null); - final ran = _slots.where((s) => s.file != null && s.hasResult).toList(); - final allDone = !_loading && ran.length == _slots.where((s) => s.file != null).length && ran.isNotEmpty; - final failCount = ran.where((s) => s.success == false).length; - - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('数据导入', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - const Text( - '选择对应的 Excel 文件后点击「全部导入」,系统将依次导入,按名称去重(已存在的数据不重复导入)。', - style: TextStyle(fontSize: 13, color: AppTheme.textSecondary), - ), - const SizedBox(height: 20), - Card( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Column( - children: [ - for (int i = 0; i < _slots.length; i++) ...[ - _buildSlotRow(i), - if (i < _slots.length - 1) - const Divider(height: 1), - ], - ], - ), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - ElevatedButton.icon( - onPressed: (_loading || !hasAnyFile) ? null : _runImport, - icon: _loading - ? const SizedBox( - width: 14, height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.upload_rounded, size: 16), - label: Text(_loading ? '导入中...' : '全部导入'), - ), - const SizedBox(width: 16), - if (allDone) ...[ - Icon( - failCount == 0 - ? Icons.check_circle_outline - : Icons.warning_amber_rounded, - size: 16, - color: failCount == 0 ? AppTheme.success : Colors.orange, - ), - const SizedBox(width: 4), - Text( - failCount == 0 ? '全部导入完成' : '$failCount 项失败,请检查错误信息', - style: TextStyle( - fontSize: 13, - color: failCount == 0 ? AppTheme.success : Colors.orange, - ), - ), - ], - ], - ), - if (widget.isSuperAdmin) ...[ - const SizedBox(height: 32), - const Divider(), - const SizedBox(height: 16), - _buildClearDataSection(), - ], - ], - ), - ); - } - - Widget _buildClearDataSection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 标题行 - Row( - children: [ - const Icon(Icons.delete_forever, color: AppTheme.danger, size: 20), - const SizedBox(width: 8), - const Text('危险操作 — 数据清空', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppTheme.danger)), - ], - ), - const SizedBox(height: 12), - // 红色警告框 - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFFFFF3F3), - border: Border.all(color: AppTheme.danger.withOpacity(0.5)), - borderRadius: BorderRadius.circular(6), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - const Icon(Icons.warning_amber_rounded, - color: AppTheme.danger, size: 18), - const SizedBox(width: 6), - const Text('警告:数据一旦清空,无法恢复!', - style: TextStyle( - color: AppTheme.danger, - fontWeight: FontWeight.w600, - fontSize: 13)), - ]), - const SizedBox(height: 6), - const Text( - '• 清空操作仅删除当前门店的数据,不影响其他门店\n' - '• 操作不可撤销,请务必提前备份数据\n' - '• 仅超级管理员可执行此操作', - style: TextStyle(fontSize: 12, color: Color(0xFFB71C1C), height: 1.6), - ), - ], - ), - ), - const SizedBox(height: 14), - // 勾选框 - Wrap( - spacing: 8, - runSpacing: 4, - children: _clearOptions.map((opt) { - final selected = _selectedTables.contains(opt.key); - return FilterChip( - label: Text(opt.label), - selected: selected, - selectedColor: AppTheme.danger.withOpacity(0.15), - checkmarkColor: AppTheme.danger, - labelStyle: TextStyle( - color: selected ? AppTheme.danger : AppTheme.textPrimary, - fontWeight: selected ? FontWeight.w600 : FontWeight.normal, - ), - side: BorderSide( - color: selected ? AppTheme.danger : Colors.grey.shade300, - ), - onSelected: _clearing - ? null - : (v) => setState(() { - if (v) { - _selectedTables.add(opt.key); - } else { - _selectedTables.remove(opt.key); - } - }), - ); - }).toList(), - ), - const SizedBox(height: 14), - // 清空按钮 - ElevatedButton.icon( - onPressed: (_clearing || _selectedTables.isEmpty) - ? null - : () => _confirmClear(), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.danger, - foregroundColor: Colors.white, - disabledBackgroundColor: Colors.grey.shade300, - ), - icon: _clearing - ? const SizedBox( - width: 14, height: 14, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.delete_sweep_outlined, size: 18), - label: Text(_clearing ? '清空中...' : '清空选中数据'), - ), - ], - ); - } - - Future _confirmClear() async { - final tables = List.from(_selectedTables); - final labels = _clearOptions - .where((o) => tables.contains(o.key)) - .map((o) => o.label) - .toList(); - - final ctrl = TextEditingController(); - final confirmed = await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setS) => AlertDialog( - title: Row(children: [ - const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22), - const SizedBox(width: 8), - const Text('确认清空数据', style: TextStyle(color: Colors.red)), - ]), - content: SizedBox( - width: context.dialogWidth(400), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('即将清空以下数据表:', - style: TextStyle(fontWeight: FontWeight.w500)), - const SizedBox(height: 8), - ...labels.map((l) => Padding( - padding: const EdgeInsets.only(left: 12, bottom: 4), - child: Row(children: [ - const Icon(Icons.remove_circle, - color: Colors.red, size: 14), - const SizedBox(width: 6), - Text(l, - style: const TextStyle( - color: Colors.red, fontWeight: FontWeight.w600)), - ]), - )), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFFFF3F3), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.red.shade200), - ), - child: const Text( - '⚠️ 此操作不可撤销,数据清空后无法恢复!', - style: TextStyle( - color: Colors.red, - fontSize: 13, - fontWeight: FontWeight.w600), - ), - ), - const SizedBox(height: 16), - const Text('请输入 "确认清空" 以继续:', - style: TextStyle(fontSize: 13)), - const SizedBox(height: 8), - TextField( - controller: ctrl, - autofocus: true, - decoration: const InputDecoration( - hintText: '确认清空', - border: OutlineInputBorder(), - isDense: true, - ), - onChanged: (_) => setS(() {}), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('取消'), - ), - ElevatedButton( - onPressed: ctrl.text == '确认清空' - ? () => Navigator.of(ctx).pop(true) - : null, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), - child: const Text('确认清空'), - ), - ], - ), - ), - ); - - ctrl.dispose(); - if (confirmed != true || !mounted) return; - - setState(() => _clearing = true); - try { - final token = ref.read(authStateProvider).user?.accessToken ?? ''; - final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl)); - dio.options.headers['Authorization'] = 'Bearer $token'; - await dio.post('/admin/clear-data', data: {'tables': tables}); - if (!mounted) return; - setState(() => _selectedTables.clear()); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('已清空:${labels.join('、')}'), - backgroundColor: AppTheme.success, - ), - ); - } on DioException catch (e) { - if (!mounted) return; - final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ?? - e.message ?? '未知错误'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('清空失败:$msg'), backgroundColor: AppTheme.danger), - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('清空失败:$e'), backgroundColor: AppTheme.danger), - ); - } finally { - if (mounted) setState(() => _clearing = false); - } - } - - Widget _buildSlotRow(int index) { - final slot = _slots[index]; - - Widget? statusWidget; - if (slot.hasResult) { - if (slot.success == true) { - final duplicate = slot.skipped + slot.updated; - final parts = [ - '共 ${slot.total} 条', - '新增 ${slot.imported} 条', - if (duplicate > 0) '重复 $duplicate 条', - ]; - statusWidget = Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.check_circle_outline, size: 15, color: AppTheme.success), - const SizedBox(width: 4), - Text( - parts.join(','), - style: const TextStyle(fontSize: 13, color: AppTheme.success), - ), - ], - ); - } else { - statusWidget = Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, size: 15, color: AppTheme.danger), - const SizedBox(width: 4), - Text( - slot.error ?? '未知错误', - style: const TextStyle(fontSize: 13, color: AppTheme.danger), - ), - ], - ); - } - } else if (_loading && slot.file != null) { - if (slot.isProcessing) { - statusWidget = Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 12, height: 12, - child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary), - ), - const SizedBox(width: 6), - Text( - '导入数据${slot.processingSeconds > 0 ? "(${slot.processingSeconds}秒)" : ""}', - style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary), - ), - ], - ); - } else { - statusWidget = Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 80, - child: LinearProgressIndicator( - value: slot.uploadPercent / 100, - backgroundColor: Colors.grey[200], - valueColor: AlwaysStoppedAnimation(AppTheme.primary), - minHeight: 6, - ), - ), - const SizedBox(width: 6), - Text( - '上传 ${slot.uploadPercent}%', - style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), - ), - ], - ); - } - } - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - SizedBox( - width: 68, - child: Text(slot.title, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), - ), - const SizedBox(width: 12), - OutlinedButton( - onPressed: _loading ? null : () => _pickFile(index), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: const Text('选择文件', style: TextStyle(fontSize: 13)), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - slot.file?.name ?? '未选择', - style: TextStyle( - fontSize: 13, - color: slot.file != null ? AppTheme.textPrimary : AppTheme.textSecondary, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 12), - if (statusWidget != null) statusWidget, - ], - ), - const SizedBox(height: 3), - Padding( - padding: const EdgeInsets.only(left: 80), - child: Text( - slot.hint, - style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary), - ), - ), - ], - ), - ); - } -} // ── 门店 Logo 预览 ──────────────────────────────────────── class _ShopLogoPreview extends StatelessWidget { diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 17a0e08..38ae326 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -42,6 +42,7 @@ class _StockOutListScreenState extends ConsumerState { Set _filterCustomer = {}; Set? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认) final _searchCtrl = TextEditingController(); + final _codeSearchCtrl = TextEditingController(); static const _screenId = 'stock_out_list'; @@ -77,6 +78,7 @@ class _StockOutListScreenState extends ConsumerState { @override void dispose() { _searchCtrl.dispose(); + _codeSearchCtrl.dispose(); super.dispose(); } @@ -84,6 +86,10 @@ class _StockOutListScreenState extends ConsumerState { .read(stockOutListProvider.notifier) .setKeyword(_searchCtrl.text.trim()); + void _triggerCodeSearch() => ref + .read(stockOutListProvider.notifier) + .setProductCode(_codeSearchCtrl.text.trim()); + String? get _startDate => _dateRange != null ? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}' : null; @@ -384,6 +390,22 @@ class _StockOutListScreenState extends ConsumerState { onSubmitted: (_) => _triggerSearch(), ); + // 商品编码精确反查(独立搜索框) + final codeSearchField = TextField( + controller: _codeSearchCtrl, + decoration: InputDecoration( + hintText: '商品编码,回车精确查', + prefixIcon: const Icon(Icons.qr_code, size: 16), + hintStyle: const TextStyle(fontSize: 12), + suffixIcon: IconButton( + icon: const Icon(Icons.search, size: 16), + tooltip: '按商品编码查', + onPressed: _triggerCodeSearch, + ), + ), + onSubmitted: (_) => _triggerCodeSearch(), + ); + final refreshBtn = IconButton( tooltip: '刷新', icon: const Icon(Icons.refresh, size: 20), @@ -417,6 +439,8 @@ class _StockOutListScreenState extends ConsumerState { children: [ searchField, const SizedBox(height: 8), + codeSearchField, + const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 4, @@ -450,6 +474,8 @@ class _StockOutListScreenState extends ConsumerState { return Row( children: [ SizedBox(width: 220, child: searchField), + const SizedBox(width: 8), + SizedBox(width: 180, child: codeSearchField), const SizedBox(width: 12), if (newBtn != null) newBtn, const Spacer(),