feat(client): 列表屏搜索前置/显示字段记忆/移动端表格切换与工具栏瘦身/退出登录归位
- 库存查询搜索框移到工具栏最前(桌面)/独占首行(移动端) - 修复入库/出库/财务「显示字段」失效:移除 minWidth 运行时强制隐藏, 用户勾选权威、minWidth 仅作首次默认;新增 ColumnPrefs 持久化记忆(四屏独立) - 移动端库存支持表格/卡片列表切换 - 移动端三个列表屏工具栏改 Wrap 紧凑布局、导出/显示字段图标化 - 退出登录统一到左侧导航:删系统设置内与桌面右上角下拉,桌面左侧栏底部新增 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Set<String>?> load(String id) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getStringList(_key(id))?.toSet();
|
||||
}
|
||||
|
||||
static Future<void> save(String id, Set<String> hidden) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_key(id), hidden.toList());
|
||||
}
|
||||
}
|
||||
@@ -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<String> _filterType = {};
|
||||
Set<String> _filterPartner = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
Set<String>? _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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<InventoryListScreen> {
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterSpec = {};
|
||||
Set<String> _filterSeries = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
Set<String>? _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<InventoryListScreen> {
|
||||
];
|
||||
|
||||
|
||||
@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<InventoryListScreen> {
|
||||
? items
|
||||
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
|
||||
|
||||
final hidden = _hiddenCols ?? <String>{};
|
||||
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<InventoryListScreen> {
|
||||
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<InventoryListScreen> {
|
||||
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(
|
||||
|
||||
@@ -79,28 +79,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// 退出登录(常驻底部;登出后路由 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -289,16 +289,6 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
label: '个人设置',
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(height: 1),
|
||||
const PopupMenuItem<String>(
|
||||
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<AppShell> {
|
||||
}).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),
|
||||
|
||||
@@ -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<StockInListScreen> {
|
||||
DateTimeRange? _dateRange;
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterSupplier = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
Set<String>? _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<StockInListScreen> {
|
||||
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<StockInListScreen> {
|
||||
required List<String> 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<StockInListScreen> {
|
||||
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(),
|
||||
|
||||
@@ -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<StockOutListScreen> {
|
||||
DateTimeRange? _dateRange;
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterCustomer = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
Set<String>? _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<StockOutListScreen> {
|
||||
required List<String> 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<StockOutListScreen> {
|
||||
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(),
|
||||
|
||||
@@ -304,15 +304,26 @@ class ColumnToggleButton extends StatelessWidget {
|
||||
final Set<String> hidden;
|
||||
final ValueChanged<Set<String>> 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(
|
||||
|
||||
Reference in New Issue
Block a user