From c4b4259a3da37726ebd13480edfec42e6a5e4022 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 15 Jun 2026 12:35:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E8=A1=A8=E6=A0=BC=E5=8A=A0?= =?UTF-8?q?=E6=A8=AA=E5=90=91=E6=BB=9A=E5=8A=A8=E6=9D=A1=20+=20=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E9=A1=B5=E5=88=97=E6=98=BE=E9=9A=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataTableCard 横向/纵向 ScrollView 各加 Scrollbar(thumbVisibility:true), Windows 鼠标用户可拖动滚动条查看右侧截断的列(6 个表格全受益) - 库存查询页新增「显示字段」按钮,可按需隐藏生产日期/供应商/备注等列, 列变少后横向溢出大幅缓解;商品编码/名称/操作三列锁定不可隐藏 Co-Authored-By: Claude Sonnet 4.6 --- .../inventory/inventory_list_screen.dart | 312 +++++++++--------- client/lib/widgets/data_table_card.dart | 45 ++- 2 files changed, 186 insertions(+), 171 deletions(-) diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index a27e718..4fdcc8a 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -12,7 +12,7 @@ import '../../core/auth/auth_state.dart'; import '../../providers/inventory_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; -import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader; +import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; import '../../core/utils/export_util.dart'; import '../../core/utils/print_util.dart'; @@ -36,6 +36,24 @@ class _InventoryListScreenState extends ConsumerState { Set _filterWarehouse = {}; Set _filterSpec = {}; Set _filterSeries = {}; + Set _hiddenCols = {}; + + static const _colDefs = [ + ColDef('code', '商品编码', required: true), + ColDef('name', '商品名称', required: true), + ColDef('spec', '规格'), + ColDef('series', '系列'), + ColDef('batch', '批次号'), + ColDef('warehouse', '仓库'), + ColDef('qty', '库存量'), + ColDef('price', '单价'), + ColDef('prodDate', '生产日期'), + ColDef('inTime', '入库时间'), + ColDef('supplier', '供应商'), + ColDef('remark', '备注'), + ColDef('status', '状态'), + ColDef('actions', '操作', required: true), + ]; @override @@ -222,6 +240,91 @@ class _InventoryListScreenState extends ConsumerState { ); } + /// 库存查询:宽屏表格单元格(按列 key 返回对应 DataCell) + DataCell _buildInventoryCell(String key, Inventory item, BuildContext context) { + return switch (key) { + 'code' => DataCell(Text( + item.productCode.isEmpty ? '-' : item.productCode, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), + 'name' => DataCell(item.productId != null + ? GestureDetector( + onTap: () => context.push('/products/${item.productId}'), + child: SizedBox( + width: 180, + child: Text( + item.productName.isEmpty ? '-' : item.productName, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppTheme.primary, + decoration: TextDecoration.underline, + decorationColor: AppTheme.primary, + ), + ), + ), + ) + : Text(item.productName.isEmpty ? '-' : item.productName, + overflow: TextOverflow.ellipsis)), + 'spec' => DataCell(Text(item.spec.isEmpty ? '-' : item.spec)), + 'series' => DataCell(Text(item.series.isEmpty ? '-' : item.series)), + 'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), + 'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)), + 'qty' => DataCell(Text( + '${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(), + style: TextStyle( + fontWeight: FontWeight.w600, + color: item.quantity == 0 + ? AppTheme.danger + : (item.minStock != null && item.quantity < item.minStock!) + ? AppTheme.accent + : AppTheme.textPrimary))), + 'price' => DataCell(Text( + item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-')), + 'prodDate' => DataCell(Text(item.productionDate ?? '-')), + 'inTime' => DataCell(Text( + item.createdAt != null && item.createdAt!.length >= 10 + ? item.createdAt!.substring(0, 10) + : '-', + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), + 'supplier' => DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)), + 'remark' => DataCell(Tooltip( + message: item.remark.isEmpty ? '' : item.remark, + waitDuration: const Duration(milliseconds: 300), + child: GestureDetector( + onTap: () => _editRemark(context, item), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.remark.isEmpty + ? '—' + : item.remark.length > 4 + ? '${item.remark.substring(0, 4)}…' + : item.remark, + style: TextStyle( + color: item.remark.isEmpty + ? AppTheme.textSecondary + : AppTheme.textPrimary, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.edit_outlined, size: 12, color: AppTheme.textSecondary), + ], + ), + ))), + 'status' => DataCell(_InventoryStatusBadge(item)), + 'actions' => DataCell(item.productId != null + ? TextButton( + onPressed: () => _printLabel(context, item), + child: const Text('打标签', + style: TextStyle(fontSize: 12, color: AppTheme.primary)), + ) + : const SizedBox()), + _ => const DataCell(SizedBox()), + }; + } + /// 库存查询:窄屏卡片 Widget _inventoryCard(Inventory item) { return MobileListCard( @@ -392,6 +495,9 @@ class _InventoryListScreenState extends ConsumerState { ? items : items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList(); + final visibleCols = + _colDefs.where((c) => !_hiddenCols.contains(c.key)).toList(); + return Column( children: [ // Summary cards(窄屏横向滚动,宽屏等分) @@ -481,6 +587,12 @@ class _InventoryListScreenState extends ConsumerState { icon: const Icon(Icons.download, size: 16), label: const Text('导出'), ), + const SizedBox(width: 8), + ColumnToggleButton( + columns: _colDefs, + hidden: _hiddenCols, + onChanged: (v) => setState(() => _hiddenCols = v), + ), const Spacer(), SizedBox( width: 220, @@ -503,68 +615,48 @@ class _InventoryListScreenState extends ConsumerState { ), mobileCards: filteredItems.map(_inventoryCard).toList(), - columns: [ - const DataColumn(label: Text('商品编码')), - const DataColumn(label: Text('商品名称')), - DataColumn( - label: FilterableColumnHeader( - text: '规格', - options: specOptions, - selected: _filterSpec, - onChanged: (v) { - setState(() => _filterSpec = v); - ref.read(inventoryListProvider.notifier).setSpec(v.toList()); - }, - ), - ), - DataColumn( - label: FilterableColumnHeader( - text: '系列', - options: seriesOptions, - selected: _filterSeries, - onChanged: (v) { - setState(() => _filterSeries = v); - ref.read(inventoryListProvider.notifier).setSeries(v.toList()); - }, - ), - ), - const DataColumn(label: Text('批次号')), - DataColumn( - label: FilterableColumnHeader( - text: '仓库', - options: warehouseOptions, - selected: _filterWarehouse, - onChanged: (v) => setState(() => _filterWarehouse = v), - ), - ), - const DataColumn(label: Text('库存量'), numeric: true), - const DataColumn(label: Text('单价'), numeric: true), - const DataColumn(label: Text('生产日期')), - const DataColumn(label: Text('入库时间')), - const DataColumn(label: Text('供应商')), - const DataColumn(label: Text('备注')), - const DataColumn(label: Text('状态')), - const DataColumn(label: Text('操作')), - ], + columns: visibleCols.map((c) { + final label = switch (c.key) { + 'spec' => FilterableColumnHeader( + text: c.label, + options: specOptions, + selected: _filterSpec, + onChanged: (v) { + setState(() => _filterSpec = v); + ref.read(inventoryListProvider.notifier).setSpec(v.toList()); + }, + ), + 'series' => FilterableColumnHeader( + text: c.label, + options: seriesOptions, + selected: _filterSeries, + onChanged: (v) { + setState(() => _filterSeries = v); + ref.read(inventoryListProvider.notifier).setSeries(v.toList()); + }, + ), + 'warehouse' => FilterableColumnHeader( + text: c.label, + options: warehouseOptions, + selected: _filterWarehouse, + onChanged: (v) => setState(() => _filterWarehouse = v), + ), + _ => Text(c.label), + }; + return DataColumn( + label: label, + numeric: c.key == 'qty' || c.key == 'price', + ); + }).toList(), rows: items.isEmpty ? [ - const DataRow(cells: [ - DataCell(SizedBox()), - DataCell(Text('暂无库存数据', + DataRow(cells: [ + const DataCell(SizedBox()), + const DataCell(Text('暂无库存数据', style: TextStyle( color: AppTheme.textSecondary))), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), - DataCell(SizedBox()), + for (int i = 2; i < visibleCols.length; i++) + const DataCell(SizedBox()), ]) ] : filteredItems @@ -572,109 +664,17 @@ class _InventoryListScreenState extends ConsumerState { color: WidgetStateProperty.resolveWith( (states) { if (item.quantity == 0) { - return AppTheme.danger.withOpacity(0.04); + return AppTheme.danger.withValues(alpha: 0.04); } if (item.minStock != null && item.quantity < item.minStock!) { - return AppTheme.accent.withOpacity(0.04); + return AppTheme.accent.withValues(alpha: 0.04); } return null; }), - cells: [ - DataCell(Text( - item.productCode.isEmpty ? '-' : item.productCode, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: AppTheme.textSecondary))), - DataCell(item.productId != null - ? GestureDetector( - onTap: () => context.push('/products/${item.productId}'), - child: SizedBox( - width: 180, - child: Text( - item.productName.isEmpty ? '-' : item.productName, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppTheme.primary, - decoration: TextDecoration.underline, - decorationColor: AppTheme.primary, - ), - ), - ), - ) - : Text(item.productName.isEmpty ? '-' : item.productName, - overflow: TextOverflow.ellipsis)), - DataCell(Text(item.spec.isEmpty ? '-' : item.spec)), - DataCell(Text(item.series.isEmpty ? '-' : item.series)), - DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo, - style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), - DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)), - DataCell(Text( - '${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(), - style: TextStyle( - fontWeight: FontWeight.w600, - color: item.quantity == 0 - ? AppTheme.danger - : (item.minStock != null && - item.quantity < - item.minStock!) - ? AppTheme.accent - : AppTheme.textPrimary), - )), - DataCell(Text( - item.unitPrice != null - ? '¥${item.unitPrice!.toStringAsFixed(2)}' - : '-', - )), - DataCell(Text(item.productionDate ?? '-')), - DataCell(Text( - item.createdAt != null && item.createdAt!.length >= 10 - ? item.createdAt!.substring(0, 10) - : '-', - style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), - )), - DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)), - DataCell( - Tooltip( - message: item.remark.isEmpty ? '' : item.remark, - waitDuration: const Duration(milliseconds: 300), - child: GestureDetector( - onTap: () => _editRemark(context, item), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.remark.isEmpty - ? '—' - : item.remark.length > 4 - ? '${item.remark.substring(0, 4)}…' - : item.remark, - style: TextStyle( - color: item.remark.isEmpty - ? AppTheme.textSecondary - : AppTheme.textPrimary, - ), - ), - const SizedBox(width: 4), - const Icon(Icons.edit_outlined, - size: 12, color: AppTheme.textSecondary), - ], - ), - ), - ), - ), - DataCell(_InventoryStatusBadge(item)), - DataCell( - item.productId != null - ? TextButton( - onPressed: () => _printLabel(context, item), - child: const Text('打标签', - style: TextStyle(fontSize: 12, color: AppTheme.primary)), - ) - : const SizedBox(), - ), - ], + cells: visibleCols + .map((c) => _buildInventoryCell(c.key, item, context)) + .toList(), )) .toList(), ), diff --git a/client/lib/widgets/data_table_card.dart b/client/lib/widgets/data_table_card.dart index b6d12ea..c0df242 100644 --- a/client/lib/widgets/data_table_card.dart +++ b/client/lib/widgets/data_table_card.dart @@ -36,10 +36,14 @@ class DataTableCard extends StatefulWidget { class _DataTableCardState extends State { // 当前悬停行的索引(-1 表示无悬停) final _hoveredRow = ValueNotifier(-1); + final _vCtrl = ScrollController(); + final _hCtrl = ScrollController(); @override void dispose() { _hoveredRow.dispose(); + _vCtrl.dispose(); + _hCtrl.dispose(); super.dispose(); } @@ -89,24 +93,35 @@ class _DataTableCardState extends State { Widget _buildTable() { return LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( + builder: (context, constraints) => Scrollbar( + controller: _vCtrl, + thumbVisibility: true, child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - border: TableBorder( - horizontalInside: BorderSide( - color: Colors.grey.shade200, - width: 0.5, + controller: _vCtrl, + child: Scrollbar( + controller: _hCtrl, + thumbVisibility: true, + scrollbarOrientation: ScrollbarOrientation.bottom, + child: SingleChildScrollView( + controller: _hCtrl, + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + border: TableBorder( + horizontalInside: BorderSide( + color: Colors.grey.shade200, + width: 0.5, + ), + ), + children: [ + _buildHeaderRow(), + for (int i = 0; i < widget.rows.length; i++) + _buildDataRow(i, widget.rows[i]), + ], ), ), - children: [ - _buildHeaderRow(), - for (int i = 0; i < widget.rows.length; i++) - _buildDataRow(i, widget.rows[i]), - ], ), ), ),