feat(client): 列表屏搜索前置/显示字段记忆/移动端表格切换与工具栏瘦身/退出登录归位

- 库存查询搜索框移到工具栏最前(桌面)/独占首行(移动端)
- 修复入库/出库/财务「显示字段」失效:移除 minWidth 运行时强制隐藏,
  用户勾选权威、minWidth 仅作首次默认;新增 ColumnPrefs 持久化记忆(四屏独立)
- 移动端库存支持表格/卡片列表切换
- 移动端三个列表屏工具栏改 Wrap 紧凑布局、导出/显示字段图标化
- 退出登录统一到左侧导航:删系统设置内与桌面右上角下拉,桌面左侧栏底部新增

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-17 18:33:31 +08:00
parent 48e282dd97
commit dfa1920dda
8 changed files with 478 additions and 204 deletions
@@ -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(),