feat(client): 表格加横向滚动条 + 库存页列显隐
- DataTableCard 横向/纵向 ScrollView 各加 Scrollbar(thumbVisibility:true), Windows 鼠标用户可拖动滚动条查看右侧截断的列(6 个表格全受益) - 库存查询页新增「显示字段」按钮,可按需隐藏生产日期/供应商/备注等列, 列变少后横向溢出大幅缓解;商品编码/名称/操作三列锁定不可隐藏 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user