feat(client): 表格加横向滚动条 + 库存页列显隐

- DataTableCard 横向/纵向 ScrollView 各加 Scrollbar(thumbVisibility:true),
  Windows 鼠标用户可拖动滚动条查看右侧截断的列(6 个表格全受益)
- 库存查询页新增「显示字段」按钮,可按需隐藏生产日期/供应商/备注等列,
  列变少后横向溢出大幅缓解;商品编码/名称/操作三列锁定不可隐藏

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-15 12:35:57 +08:00
parent 68e0ca30ad
commit c4b4259a3d
2 changed files with 186 additions and 171 deletions
@@ -12,7 +12,7 @@ import '../../core/auth/auth_state.dart';
import '../../providers/inventory_provider.dart'; import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart'; import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_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 '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart'; import '../../core/utils/print_util.dart';
@@ -36,6 +36,24 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
Set<String> _filterWarehouse = {}; Set<String> _filterWarehouse = {};
Set<String> _filterSpec = {}; Set<String> _filterSpec = {};
Set<String> _filterSeries = {}; Set<String> _filterSeries = {};
Set<String> _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 @override
@@ -222,6 +240,91 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
); );
} }
/// 库存查询:宽屏表格单元格(按列 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) { Widget _inventoryCard(Inventory item) {
return MobileListCard( return MobileListCard(
@@ -392,6 +495,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
? items ? items
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList(); : items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
final visibleCols =
_colDefs.where((c) => !_hiddenCols.contains(c.key)).toList();
return Column( return Column(
children: [ children: [
// Summary cards(窄屏横向滚动,宽屏等分) // Summary cards(窄屏横向滚动,宽屏等分)
@@ -481,6 +587,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
icon: const Icon(Icons.download, size: 16), icon: const Icon(Icons.download, size: 16),
label: const Text('导出'), label: const Text('导出'),
), ),
const SizedBox(width: 8),
ColumnToggleButton(
columns: _colDefs,
hidden: _hiddenCols,
onChanged: (v) => setState(() => _hiddenCols = v),
),
const Spacer(), const Spacer(),
SizedBox( SizedBox(
width: 220, width: 220,
@@ -503,68 +615,48 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
), ),
mobileCards: mobileCards:
filteredItems.map(_inventoryCard).toList(), filteredItems.map(_inventoryCard).toList(),
columns: [ columns: visibleCols.map((c) {
const DataColumn(label: Text('商品编码')), final label = switch (c.key) {
const DataColumn(label: Text('商品名称')), 'spec' => FilterableColumnHeader(
DataColumn( text: c.label,
label: FilterableColumnHeader( options: specOptions,
text: '规格', selected: _filterSpec,
options: specOptions, onChanged: (v) {
selected: _filterSpec, setState(() => _filterSpec = v);
onChanged: (v) { ref.read(inventoryListProvider.notifier).setSpec(v.toList());
setState(() => _filterSpec = v); },
ref.read(inventoryListProvider.notifier).setSpec(v.toList()); ),
}, 'series' => FilterableColumnHeader(
), text: c.label,
), options: seriesOptions,
DataColumn( selected: _filterSeries,
label: FilterableColumnHeader( onChanged: (v) {
text: '系列', setState(() => _filterSeries = v);
options: seriesOptions, ref.read(inventoryListProvider.notifier).setSeries(v.toList());
selected: _filterSeries, },
onChanged: (v) { ),
setState(() => _filterSeries = v); 'warehouse' => FilterableColumnHeader(
ref.read(inventoryListProvider.notifier).setSeries(v.toList()); text: c.label,
}, options: warehouseOptions,
), selected: _filterWarehouse,
), onChanged: (v) => setState(() => _filterWarehouse = v),
const DataColumn(label: Text('批次号')), ),
DataColumn( _ => Text(c.label),
label: FilterableColumnHeader( };
text: '仓库', return DataColumn(
options: warehouseOptions, label: label,
selected: _filterWarehouse, numeric: c.key == 'qty' || c.key == 'price',
onChanged: (v) => setState(() => _filterWarehouse = v), );
), }).toList(),
),
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('操作')),
],
rows: items.isEmpty rows: items.isEmpty
? [ ? [
const DataRow(cells: [ DataRow(cells: [
DataCell(SizedBox()), const DataCell(SizedBox()),
DataCell(Text('暂无库存数据', const DataCell(Text('暂无库存数据',
style: TextStyle( style: TextStyle(
color: AppTheme.textSecondary))), color: AppTheme.textSecondary))),
DataCell(SizedBox()), for (int i = 2; i < visibleCols.length; i++)
DataCell(SizedBox()), const DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
]) ])
] ]
: filteredItems : filteredItems
@@ -572,109 +664,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
color: WidgetStateProperty.resolveWith( color: WidgetStateProperty.resolveWith(
(states) { (states) {
if (item.quantity == 0) { if (item.quantity == 0) {
return AppTheme.danger.withOpacity(0.04); return AppTheme.danger.withValues(alpha: 0.04);
} }
if (item.minStock != null && if (item.minStock != null &&
item.quantity < item.minStock!) { item.quantity < item.minStock!) {
return AppTheme.accent.withOpacity(0.04); return AppTheme.accent.withValues(alpha: 0.04);
} }
return null; return null;
}), }),
cells: [ cells: visibleCols
DataCell(Text( .map((c) => _buildInventoryCell(c.key, item, context))
item.productCode.isEmpty ? '-' : item.productCode, .toList(),
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(),
),
],
)) ))
.toList(), .toList(),
), ),
+30 -15
View File
@@ -36,10 +36,14 @@ class DataTableCard extends StatefulWidget {
class _DataTableCardState extends State<DataTableCard> { class _DataTableCardState extends State<DataTableCard> {
// 当前悬停行的索引(-1 表示无悬停) // 当前悬停行的索引(-1 表示无悬停)
final _hoveredRow = ValueNotifier<int>(-1); final _hoveredRow = ValueNotifier<int>(-1);
final _vCtrl = ScrollController();
final _hCtrl = ScrollController();
@override @override
void dispose() { void dispose() {
_hoveredRow.dispose(); _hoveredRow.dispose();
_vCtrl.dispose();
_hCtrl.dispose();
super.dispose(); super.dispose();
} }
@@ -89,24 +93,35 @@ class _DataTableCardState extends State<DataTableCard> {
Widget _buildTable() { Widget _buildTable() {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView( builder: (context, constraints) => Scrollbar(
controller: _vCtrl,
thumbVisibility: true,
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, controller: _vCtrl,
child: ConstrainedBox( child: Scrollbar(
constraints: BoxConstraints(minWidth: constraints.maxWidth), controller: _hCtrl,
child: Table( thumbVisibility: true,
defaultColumnWidth: const IntrinsicColumnWidth(), scrollbarOrientation: ScrollbarOrientation.bottom,
border: TableBorder( child: SingleChildScrollView(
horizontalInside: BorderSide( controller: _hCtrl,
color: Colors.grey.shade200, scrollDirection: Axis.horizontal,
width: 0.5, 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]),
],
), ),
), ),
), ),