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 '../../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<InventoryListScreen> {
Set<String> _filterWarehouse = {};
Set<String> _filterSpec = {};
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
@@ -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) {
return MobileListCard(
@@ -392,6 +495,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
? 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<InventoryListScreen> {
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<InventoryListScreen> {
),
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<InventoryListScreen> {
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(),
),
+30 -15
View File
@@ -36,10 +36,14 @@ class DataTableCard extends StatefulWidget {
class _DataTableCardState extends State<DataTableCard> {
// 当前悬停行的索引(-1 表示无悬停)
final _hoveredRow = ValueNotifier<int>(-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<DataTableCard> {
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]),
],
),
),
),