From dfa1920ddaeb4542f325f3b407ce5a9115620f6c Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 17 Jun 2026 18:33:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=88=97=E8=A1=A8=E5=B1=8F?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=89=8D=E7=BD=AE/=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E8=AE=B0=E5=BF=86/=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E8=A1=A8=E6=A0=BC=E5=88=87=E6=8D=A2=E4=B8=8E=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=A0=8F=E7=98=A6=E8=BA=AB/=E9=80=80=E5=87=BA?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=BD=92=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 库存查询搜索框移到工具栏最前(桌面)/独占首行(移动端) - 修复入库/出库/财务「显示字段」失效:移除 minWidth 运行时强制隐藏, 用户勾选权威、minWidth 仅作首次默认;新增 ColumnPrefs 持久化记忆(四屏独立) - 移动端库存支持表格/卡片列表切换 - 移动端三个列表屏工具栏改 Wrap 紧凑布局、导出/显示字段图标化 - 退出登录统一到左侧导航:删系统设置内与桌面右上角下拉,桌面左侧栏底部新增 Co-Authored-By: Claude Opus 4.8 --- client/lib/core/storage/column_prefs.dart | 20 ++ .../lib/screens/finance/finance_screen.dart | 29 ++- .../inventory/inventory_list_screen.dart | 162 +++++++++++---- .../lib/screens/settings/settings_screen.dart | 22 -- client/lib/screens/shell/app_shell.dart | 62 ++++-- .../stock_in/stock_in_list_screen.dart | 188 ++++++++++++------ .../stock_out/stock_out_list_screen.dart | 188 ++++++++++++------ client/lib/widgets/multi_select_dropdown.dart | 11 + 8 files changed, 478 insertions(+), 204 deletions(-) create mode 100644 client/lib/core/storage/column_prefs.dart diff --git a/client/lib/core/storage/column_prefs.dart b/client/lib/core/storage/column_prefs.dart new file mode 100644 index 0000000..6811dab --- /dev/null +++ b/client/lib/core/storage/column_prefs.dart @@ -0,0 +1,20 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// 列表屏「显示字段」(列显隐)的本地持久化。 +/// +/// 每个屏用各自的 [id](如 `inventory_list` / `stock_in_list`)独立存储。 +/// 存的是「隐藏列的 key 集合」。[load] 返回 null 表示该屏从未保存过, +/// 调用方据此回退到按 minWidth 计算的首次默认隐藏集。 +class ColumnPrefs { + static String _key(String id) => 'hidden_cols:$id'; + + static Future?> load(String id) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getStringList(_key(id))?.toSet(); + } + + static Future save(String id, Set hidden) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(_key(id), hidden.toList()); + } +} diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index 0f356b1..8f3b6bb 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -7,6 +7,7 @@ import '../../providers/finance_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; +import '../../core/storage/column_prefs.dart'; import '../../widgets/page_scaffold.dart'; import '../../providers/connectivity_provider.dart'; import '../../core/utils/export_util.dart'; @@ -53,7 +54,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { Set _filterType = {}; Set _filterPartner = {}; - Set _hiddenCols = {}; + Set? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认) + + static const _screenId = 'finance'; static const _colDefs = [ ColDef('date', '日期', required: true), @@ -73,6 +76,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { final now = DateTime.now(); _month = '${now.year}-${now.month.toString().padLeft(2, '0')}'; _fetch(); + ColumnPrefs.load(_screenId).then((saved) { + if (saved != null && mounted) setState(() => _hiddenCols = saved); + }); } void _fetch() { @@ -230,11 +236,15 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { ..sort(); final screenWidth = MediaQuery.of(context).size.width; - final visibleCols = _colDefs - .where((c) => - !_hiddenCols.contains(c.key) && - (c.minWidth == null || screenWidth >= c.minWidth!)) - .toList(); + // 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。 + final hidden = _hiddenCols ?? + _colDefs + .where((c) => c.minWidth != null && screenWidth < c.minWidth!) + .map((c) => c.key) + .toSet(); + // 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。 + final visibleCols = + _colDefs.where((c) => !hidden.contains(c.key)).toList(); final columns = visibleCols.map((c) { final label = switch (c.key) { @@ -455,8 +465,11 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { const SizedBox(width: 8), ColumnToggleButton( columns: _colDefs, - hidden: _hiddenCols, - onChanged: (v) => setState(() => _hiddenCols = v), + hidden: hidden, + onChanged: (v) { + setState(() => _hiddenCols = v); + ColumnPrefs.save(_screenId, v); + }, ), ], ), diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index b79ed85..6113c82 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -14,6 +14,7 @@ import '../../providers/inventory_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; +import '../../core/storage/column_prefs.dart'; import '../../widgets/page_scaffold.dart'; import '../../core/utils/export_util.dart'; import '../../core/utils/print_util.dart'; @@ -37,7 +38,10 @@ class _InventoryListScreenState extends ConsumerState { Set _filterWarehouse = {}; Set _filterSpec = {}; Set _filterSeries = {}; - Set _hiddenCols = {}; + Set? _hiddenCols; // null = 尚未载入本地存档 + bool _mobileTableView = false; // 移动端:false=卡片列表,true=表格(横向滚动) + + static const _screenId = 'inventory_list'; static const _colDefs = [ ColDef('code', '商品编码', required: true), @@ -57,6 +61,14 @@ class _InventoryListScreenState extends ConsumerState { ]; + @override + void initState() { + super.initState(); + ColumnPrefs.load(_screenId).then((saved) { + if (saved != null && mounted) setState(() => _hiddenCols = saved); + }); + } + @override void dispose() { _searchCtrl.dispose(); @@ -499,8 +511,9 @@ class _InventoryListScreenState extends ConsumerState { ? items : items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList(); + final hidden = _hiddenCols ?? {}; final visibleCols = - _colDefs.where((c) => !_hiddenCols.contains(c.key)).toList(); + _colDefs.where((c) => !hidden.contains(c.key)).toList(); return Column( children: [ @@ -553,18 +566,9 @@ class _InventoryListScreenState extends ConsumerState { ref.read(inventoryListProvider.notifier).setPage(p), onPageSizeChanged: (s) => ref.read(inventoryListProvider.notifier).setPageSize(s), - toolbar: Row( - children: [ - if (!WriteGuard.isReadonly(ref)) ...[ - OutlinedButton.icon( - onPressed: () => context.go('/inventory/check'), - icon: const Icon(Icons.fact_check, size: 16), - label: const Text('发起盘点'), - ), - const SizedBox(width: 8), - ], - OutlinedButton.icon( - onPressed: () => exportExcel( + toolbar: Builder(builder: (ctx) { + final isMobile = ctx.isMobile; + void doExport() => exportExcel( filename: '库存查询', headers: ['商品编码', '商品名称', '规格', '系列', '批次号', '仓库', '库存量', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'], rows: filteredItems.map((item) { @@ -589,38 +593,112 @@ class _InventoryListScreenState extends ConsumerState { status, ]; }).toList(), + ); + + final searchField = TextField( + controller: _searchCtrl, + decoration: InputDecoration( + hintText: '名称/编码/拼音,回车搜索', + prefixIcon: const Icon(Icons.search, size: 16), + hintStyle: const TextStyle(fontSize: 12), + suffixIcon: IconButton( + icon: const Icon(Icons.search, size: 16), + tooltip: '搜索', + onPressed: _triggerSearch, ), - 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, - child: TextField( - controller: _searchCtrl, - decoration: InputDecoration( - hintText: '名称/编码/拼音,回车搜索', - prefixIcon: const Icon(Icons.search, size: 16), - hintStyle: const TextStyle(fontSize: 12), - suffixIcon: IconButton( - icon: const Icon(Icons.search, size: 16), - tooltip: '搜索', - onPressed: _triggerSearch, - ), + onSubmitted: (_) => _triggerSearch(), + ); + + final canCheck = !WriteGuard.isReadonly(ref); + + if (isMobile) { + // 移动端:搜索独占一行 + 紧凑图标操作行(含 表格/列表 切换) + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + searchField, + const SizedBox(height: 8), + Row( + children: [ + if (canCheck) + OutlinedButton.icon( + onPressed: () => context.go('/inventory/check'), + icon: const Icon(Icons.fact_check, size: 16), + label: const Text('盘点'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + ), + const Spacer(), + IconButton( + tooltip: _mobileTableView ? '卡片视图' : '表格视图', + icon: Icon( + _mobileTableView + ? Icons.view_agenda_outlined + : Icons.table_rows_outlined, + size: 20), + onPressed: () => setState( + () => _mobileTableView = !_mobileTableView), + ), + IconButton( + tooltip: '导出', + icon: const Icon(Icons.download, size: 20), + onPressed: doExport, + ), + ColumnToggleButton( + columns: _colDefs, + hidden: hidden, + compact: true, + onChanged: (v) { + setState(() => _hiddenCols = v); + ColumnPrefs.save(_screenId, v); + }, + ), + ], ), - onSubmitted: (_) => _triggerSearch(), + ], + ); + } + + // 桌面:搜索置于最前,按钮成组靠左 + return Row( + children: [ + SizedBox(width: 220, child: searchField), + const SizedBox(width: 12), + if (canCheck) ...[ + OutlinedButton.icon( + onPressed: () => context.go('/inventory/check'), + icon: const Icon(Icons.fact_check, size: 16), + label: const Text('发起盘点'), + ), + const SizedBox(width: 8), + ], + OutlinedButton.icon( + onPressed: doExport, + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), ), - ), - ], - ), - mobileCards: - filteredItems.map(_inventoryCard).toList(), + const SizedBox(width: 8), + ColumnToggleButton( + columns: _colDefs, + hidden: hidden, + onChanged: (v) { + setState(() => _hiddenCols = v); + ColumnPrefs.save(_screenId, v); + }, + ), + const Spacer(), + ], + ); + }), + mobileCards: _mobileTableView + ? null + : filteredItems.map(_inventoryCard).toList(), columns: visibleCols.map((c) { final label = switch (c.key) { 'spec' => FilterableColumnHeader( diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index 7968796..21798eb 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -79,28 +79,6 @@ class _SettingsScreenState extends ConsumerState { ], ), ), - // 退出登录(常驻底部;登出后路由 redirect 自动跳 /login) - const Divider(height: 1), - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), - child: SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => - ref.read(authStateProvider.notifier).logout(), - icon: const Icon(Icons.logout, size: 18), - label: const Text('退出登录'), - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.danger, - side: const BorderSide(color: AppTheme.danger), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - ), - ), ], ), ); diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 2cd57e5..67ab9fc 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -289,16 +289,6 @@ class _AppShellState extends ConsumerState { label: '个人设置', ), ), - const PopupMenuDivider(height: 1), - const PopupMenuItem( - value: 'logout', - padding: EdgeInsets.zero, - child: _HoverMenuItem( - icon: Icons.logout_outlined, - label: '退出登录', - danger: true, - ), - ), ], ), const SizedBox(width: 8), @@ -334,6 +324,48 @@ class _AppShellState extends ConsumerState { }).toList(), ), ), + // 退出登录(侧栏底部常驻;统一为左侧导航唯一退出入口) + const Divider(height: 1, color: Colors.white24), + SizedBox( + height: 48, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + ref + .read(authStateProvider.notifier) + .logout(); + context.go('/login'); + }, + hoverColor: Colors.white.withAlpha(13), + splashColor: Colors.white.withAlpha(26), + child: Row( + children: [ + SizedBox(width: _sidebarExpanded ? 16 : 3), + Expanded( + child: Row( + mainAxisAlignment: _sidebarExpanded + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + const Icon(Icons.logout, + color: Colors.white60, size: 20), + if (_sidebarExpanded) ...[ + const SizedBox(width: 12), + const Text('退出登录', + style: TextStyle( + color: Colors.white70, + fontSize: 14)), + ], + ], + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 8), ], ), ), @@ -930,12 +962,10 @@ class _InfoRow extends StatelessWidget { class _HoverMenuItem extends StatefulWidget { final IconData icon; final String label; - final bool danger; const _HoverMenuItem({ required this.icon, required this.label, - this.danger = false, }); @override @@ -947,12 +977,8 @@ class _HoverMenuItemState extends State<_HoverMenuItem> { @override Widget build(BuildContext context) { - final color = widget.danger - ? const Color(0xFFE53935) - : const Color(0xFF333333); - final hoverBg = widget.danger - ? const Color(0xFFFFF0F0) - : const Color(0xFFF0F4FF); + const color = Color(0xFF333333); + const hoverBg = Color(0xFFF0F4FF); return MouseRegion( onEnter: (_) => setState(() => _hovered = true), diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 6c2830d..94845b1 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -13,6 +13,7 @@ import '../../repositories/stock_in_repository.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; +import '../../core/storage/column_prefs.dart'; import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; import '../../core/utils/export_util.dart'; @@ -36,7 +37,9 @@ class _StockInListScreenState extends ConsumerState { DateTimeRange? _dateRange; Set _filterWarehouse = {}; Set _filterSupplier = {}; - Set _hiddenCols = {}; + Set? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认) + + static const _screenId = 'stock_in_list'; static const _colDefs = [ ColDef('order_no', '入库单号', required: true), @@ -51,6 +54,14 @@ class _StockInListScreenState extends ConsumerState { ColDef('actions', '操作', required: true), ]; + @override + void initState() { + super.initState(); + ColumnPrefs.load(_screenId).then((saved) { + if (saved != null && mounted) setState(() => _hiddenCols = saved); + }); + } + String? get _startDate => _dateRange != null ? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}' : null; @@ -177,11 +188,15 @@ class _StockInListScreenState extends ConsumerState { required List supplierOptions, }) { final screenWidth = MediaQuery.of(context).size.width; - final visibleCols = _colDefs - .where((c) => - !_hiddenCols.contains(c.key) && - (c.minWidth == null || screenWidth >= c.minWidth!)) - .toList(); + // 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。 + final hidden = _hiddenCols ?? + _colDefs + .where((c) => c.minWidth != null && screenWidth < c.minWidth!) + .map((c) => c.key) + .toSet(); + // 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。 + final visibleCols = + _colDefs.where((c) => !hidden.contains(c.key)).toList(); final columns = visibleCols.map((c) { final label = switch (c.key) { @@ -279,64 +294,123 @@ class _StockInListScreenState extends ConsumerState { ref.read(stockInListProvider.notifier).setPage(p), onPageSizeChanged: (s) => ref.read(stockInListProvider.notifier).setPageSize(s), - toolbar: Row( - children: [ - if (showNewButton && !WriteGuard.isReadonly(ref)) - ElevatedButton.icon( - onPressed: () => context.go('/stock-in/new'), - icon: const Icon(Icons.add, size: 16), - label: const Text('新建入库审核单'), - ), - const Spacer(), - if (showStatusFilter) ...[ - _StatusFilterDropdown( - value: _statusFilter, - onChanged: (v) { - setState(() => _statusFilter = v ?? ''); - ref.read(stockInListProvider.notifier).setStatus(v ?? ''); - }, - ), - const SizedBox(width: 8), - ], - OutlinedButton.icon( - onPressed: _pickDateRange, - icon: const Icon(Icons.date_range, size: 16), - label: Text( - _dateRange == null ? '选择日期' : '$_startDate ~ $_endDate', - style: const TextStyle(fontSize: 13), - ), - ), - if (_dateRange != null) ...[ - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.clear, size: 16), - onPressed: () { - setState(() => _dateRange = null); - ref.read(stockInListProvider.notifier).setDateRange(null, null); - }, - ), - ], - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () => exportExcel( + toolbar: Builder(builder: (ctx) { + final isMobile = ctx.isMobile; + void doExport() => exportExcel( filename: '入库单列表', headers: ['单号', '仓库', '供应商', '状态', '日期', '总金额'], rows: orders.map((o) => [ o.orderNo, o.warehouseName ?? '', o.partnerName ?? '', o.status, o.orderDate, o.totalAmount, ]).toList(), + ); + + final newBtn = (showNewButton && !WriteGuard.isReadonly(ref)) + ? ElevatedButton.icon( + onPressed: () => context.go('/stock-in/new'), + icon: const Icon(Icons.add, size: 16), + label: Text(isMobile ? '新建' : '新建入库审核单'), + style: isMobile + ? ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + : null, + ) + : null; + + final statusFilter = showStatusFilter + ? _StatusFilterDropdown( + value: _statusFilter, + onChanged: (v) { + setState(() => _statusFilter = v ?? ''); + ref.read(stockInListProvider.notifier).setStatus(v ?? ''); + }, + ) + : null; + + final dateBtn = OutlinedButton.icon( + onPressed: _pickDateRange, + icon: const Icon(Icons.date_range, size: 16), + label: Text( + _dateRange == null ? '选择日期' : '$_startDate ~ $_endDate', + style: const TextStyle(fontSize: 13), + ), + ); + + final clearDate = _dateRange != null + ? IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + setState(() => _dateRange = null); + ref + .read(stockInListProvider.notifier) + .setDateRange(null, null); + }, + ) + : null; + + if (isMobile) { + return Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (newBtn != null) newBtn, + if (statusFilter != null) statusFilter, + dateBtn, + if (clearDate != null) clearDate, + IconButton( + tooltip: '导出', + icon: const Icon(Icons.download, size: 20), + onPressed: doExport, + ), + ColumnToggleButton( + columns: _colDefs, + hidden: hidden, + compact: true, + onChanged: (v) { + setState(() => _hiddenCols = v); + ColumnPrefs.save(_screenId, v); + }, + ), + ], + ); + } + + return Row( + children: [ + if (newBtn != null) newBtn, + const Spacer(), + if (statusFilter != null) ...[ + statusFilter, + const SizedBox(width: 8), + ], + dateBtn, + if (clearDate != null) ...[ + const SizedBox(width: 4), + clearDate, + ], + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: doExport, + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), ), - 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 SizedBox(width: 8), + ColumnToggleButton( + columns: _colDefs, + hidden: hidden, + onChanged: (v) { + setState(() => _hiddenCols = v); + ColumnPrefs.save(_screenId, v); + }, + ), + ], + ); + }), columns: columns, rows: rows, mobileCards: orders.map((o) => _orderCard(context, o)).toList(), diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 8fbf3eb..ef55a58 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -12,6 +12,7 @@ import '../../repositories/stock_out_repository.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; +import '../../core/storage/column_prefs.dart'; import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; import '../../core/utils/export_util.dart'; @@ -35,7 +36,17 @@ class _StockOutListScreenState extends ConsumerState { DateTimeRange? _dateRange; Set _filterWarehouse = {}; Set _filterCustomer = {}; - Set _hiddenCols = {}; + Set? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认) + + static const _screenId = 'stock_out_list'; + + @override + void initState() { + super.initState(); + ColumnPrefs.load(_screenId).then((saved) { + if (saved != null && mounted) setState(() => _hiddenCols = saved); + }); + } static const _colDefs = [ ColDef('order_no', '出库单号', required: true), @@ -177,11 +188,15 @@ class _StockOutListScreenState extends ConsumerState { required List customerOptions, }) { final screenWidth = MediaQuery.of(context).size.width; - final visibleCols = _colDefs - .where((c) => - !_hiddenCols.contains(c.key) && - (c.minWidth == null || screenWidth >= c.minWidth!)) - .toList(); + // 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。 + final hidden = _hiddenCols ?? + _colDefs + .where((c) => c.minWidth != null && screenWidth < c.minWidth!) + .map((c) => c.key) + .toSet(); + // 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。 + final visibleCols = + _colDefs.where((c) => !hidden.contains(c.key)).toList(); final columns = visibleCols.map((c) { final label = switch (c.key) { @@ -285,64 +300,123 @@ class _StockOutListScreenState extends ConsumerState { ref.read(stockOutListProvider.notifier).setPage(p), onPageSizeChanged: (s) => ref.read(stockOutListProvider.notifier).setPageSize(s), - toolbar: Row( - children: [ - if (showNewButton && !WriteGuard.isReadonly(ref)) - ElevatedButton.icon( - onPressed: () => context.go('/stock-out/new'), - icon: const Icon(Icons.add, size: 16), - label: const Text('新建出库审核单'), - ), - const Spacer(), - if (showStatusFilter) ...[ - _StatusFilterDropdown( - value: _statusFilter, - onChanged: (v) { - setState(() => _statusFilter = v ?? ''); - ref.read(stockOutListProvider.notifier).setStatus(v ?? ''); - }, - ), - const SizedBox(width: 8), - ], - OutlinedButton.icon( - onPressed: _pickDateRange, - icon: const Icon(Icons.date_range, size: 16), - label: Text( - _dateRange == null ? '选择日期' : '$_startDate ~ $_endDate', - style: const TextStyle(fontSize: 13), - ), - ), - if (_dateRange != null) ...[ - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.clear, size: 16), - onPressed: () { - setState(() => _dateRange = null); - ref.read(stockOutListProvider.notifier).setDateRange(null, null); - }, - ), - ], - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () => exportExcel( + toolbar: Builder(builder: (ctx) { + final isMobile = ctx.isMobile; + void doExport() => exportExcel( filename: '出库单列表', headers: ['单号', '仓库', '客户', '状态', '日期', '总金额'], rows: orders.map((o) => [ o.orderNo, o.warehouseName ?? '', o.partnerName ?? '', o.status, o.orderDate, o.totalAmount, ]).toList(), + ); + + final newBtn = (showNewButton && !WriteGuard.isReadonly(ref)) + ? ElevatedButton.icon( + onPressed: () => context.go('/stock-out/new'), + icon: const Icon(Icons.add, size: 16), + label: Text(isMobile ? '新建' : '新建出库审核单'), + style: isMobile + ? ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + : null, + ) + : null; + + final statusFilter = showStatusFilter + ? _StatusFilterDropdown( + value: _statusFilter, + onChanged: (v) { + setState(() => _statusFilter = v ?? ''); + ref.read(stockOutListProvider.notifier).setStatus(v ?? ''); + }, + ) + : null; + + final dateBtn = OutlinedButton.icon( + onPressed: _pickDateRange, + icon: const Icon(Icons.date_range, size: 16), + label: Text( + _dateRange == null ? '选择日期' : '$_startDate ~ $_endDate', + style: const TextStyle(fontSize: 13), + ), + ); + + final clearDate = _dateRange != null + ? IconButton( + icon: const Icon(Icons.clear, size: 16), + onPressed: () { + setState(() => _dateRange = null); + ref + .read(stockOutListProvider.notifier) + .setDateRange(null, null); + }, + ) + : null; + + if (isMobile) { + return Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (newBtn != null) newBtn, + if (statusFilter != null) statusFilter, + dateBtn, + if (clearDate != null) clearDate, + IconButton( + tooltip: '导出', + icon: const Icon(Icons.download, size: 20), + onPressed: doExport, + ), + ColumnToggleButton( + columns: _colDefs, + hidden: hidden, + compact: true, + onChanged: (v) { + setState(() => _hiddenCols = v); + ColumnPrefs.save(_screenId, v); + }, + ), + ], + ); + } + + return Row( + children: [ + if (newBtn != null) newBtn, + const Spacer(), + if (statusFilter != null) ...[ + statusFilter, + const SizedBox(width: 8), + ], + dateBtn, + if (clearDate != null) ...[ + const SizedBox(width: 4), + clearDate, + ], + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: doExport, + icon: const Icon(Icons.download, size: 16), + label: const Text('导出'), ), - 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 SizedBox(width: 8), + ColumnToggleButton( + columns: _colDefs, + hidden: hidden, + onChanged: (v) { + setState(() => _hiddenCols = v); + ColumnPrefs.save(_screenId, v); + }, + ), + ], + ); + }), columns: columns, rows: rows, mobileCards: orders.map((o) => _orderCard(context, o)).toList(), diff --git a/client/lib/widgets/multi_select_dropdown.dart b/client/lib/widgets/multi_select_dropdown.dart index 67e8665..b354988 100644 --- a/client/lib/widgets/multi_select_dropdown.dart +++ b/client/lib/widgets/multi_select_dropdown.dart @@ -304,15 +304,26 @@ class ColumnToggleButton extends StatelessWidget { final Set hidden; final ValueChanged> onChanged; + /// 紧凑模式:只显示图标(移动端用,省空间)。 + final bool compact; + const ColumnToggleButton({ super.key, required this.columns, required this.hidden, required this.onChanged, + this.compact = false, }); @override Widget build(BuildContext context) { + if (compact) { + return IconButton( + tooltip: '显示字段', + icon: const Icon(Icons.view_column_outlined, size: 20), + onPressed: () => _show(context), + ); + } return OutlinedButton( onPressed: () => _show(context), style: OutlinedButton.styleFrom(