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 '../../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(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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]),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user