feat(client): 录单表单优化 + 可搜索下拉支持新建 + 日期选择器组件

新增 DatePickerField 日期选择器与 date_util;可搜索下拉 SearchableOptionField
支持内联新建选项;入库/出库录单表单、商品页、财务页、app_shell 导航相应调整。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-20 07:05:22 +08:00
parent e892ab45b2
commit a76724b385
12 changed files with 2868 additions and 1624 deletions
+109 -92
View File
@@ -6,13 +6,16 @@ import '../../models/finance.dart';
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 '../../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';
import '../../core/utils/date_util.dart';
import '../../repositories/finance_repository.dart';
import '../../widgets/write_guard.dart';
import '../../widgets/date_picker_field.dart';
class FinanceScreen extends ConsumerWidget {
const FinanceScreen({super.key});
@@ -140,11 +143,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
/// 财务记录:窄屏卡片
Widget _financeCard(FinanceRecord r) {
final canClose = (r.type == 'payable' || r.type == 'receivable') &&
r.status == 'open';
final canClose =
(r.type == 'payable' || r.type == 'receivable') && r.status == 'open';
final showStatus = r.type == 'payable' || r.type == 'receivable';
return MobileListCard(
title: Text(r.partnerName?.isNotEmpty == true ? r.partnerName! : r.typeLabel),
title: Text(
r.partnerName?.isNotEmpty == true ? r.partnerName! : r.typeLabel),
subtitle: Text(r.recordDate?.substring(0, 10) ?? '-'),
trailing: _TypeBadge(r.typeLabel),
fields: [
@@ -154,9 +158,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
'¥${r.balance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 13,
color: r.balance > 0
? AppTheme.danger
: AppTheme.textSecondary,
color: r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary,
fontWeight: FontWeight.w600,
),
)),
@@ -204,9 +206,11 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const Icon(Icons.cloud_off,
size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)),
const Text('暂无数据,网络不可用',
style: TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(onPressed: _refetch, child: const Text('重试')),
],
@@ -223,13 +227,18 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
// Summary: only for payable/receivable tabs
final totalAmount = records.fold(0.0, (s, r) => s + r.amount);
final openAmount = records
.where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'open')
.where((r) =>
(r.type == 'payable' || r.type == 'receivable') &&
r.status == 'open')
.fold(0.0, (s, r) => s + r.amount);
final closedAmount = records
.where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'closed')
.where((r) =>
(r.type == 'payable' || r.type == 'receivable') &&
r.status == 'closed')
.fold(0.0, (s, r) => s + r.amount);
final typeOptions = _allRecords.map((r) => r.typeLabel).toSet().toList()..sort();
final typeOptions = _allRecords.map((r) => r.typeLabel).toSet().toList()
..sort();
final partnerOptions = _allRecords
.map((r) => r.partnerName ?? '')
.where((s) => s.isNotEmpty)
@@ -245,8 +254,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
.map((c) => c.key)
.toSet();
// 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。
final visibleCols =
_colDefs.where((c) => !hidden.contains(c.key)).toList();
final visibleCols = _colDefs.where((c) => !hidden.contains(c.key)).toList();
final columns = visibleCols.map((c) {
final label = switch (c.key) {
@@ -265,8 +273,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
_ => Text(c.label),
};
return DataColumn(
label: label,
numeric: c.key == 'amount' || c.key == 'balance');
label: label, numeric: c.key == 'amount' || c.key == 'balance');
}).toList();
DataCell buildFinanceCell(String key, FinanceRecord r) {
@@ -314,7 +321,8 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
width: 160,
child: Text(r.remark ?? '-',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
));
case 'actions':
if ((r.type == 'payable' || r.type == 'receivable') &&
@@ -348,7 +356,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
]
: records
.map((r) => DataRow(
cells: visibleCols.map((c) => buildFinanceCell(c.key, r)).toList(),
cells: visibleCols
.map((c) => buildFinanceCell(c.key, r))
.toList(),
))
.toList();
@@ -441,19 +451,30 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
: '应收账款';
exportExcel(
filename: tabName,
headers: ['日期', '类型', '往来单位', '关联单据', '金额', '余额', '状态', '备注'],
rows: records.map((r) => [
r.recordDate?.substring(0, 10) ?? '',
r.typeLabel,
r.partnerName ?? '',
r.refType != null && r.refId != null
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
: '',
r.amount,
r.balance,
r.status == 'open' ? '未结清' : '已结清',
r.remark ?? '',
]).toList(),
headers: [
'日期',
'类型',
'往来单位',
'关联单据',
'金额',
'余额',
'状态',
'备注'
],
rows: records
.map((r) => [
r.recordDate?.substring(0, 10) ?? '',
r.typeLabel,
r.partnerName ?? '',
r.refType != null && r.refId != null
? '${r.refType!.replaceAll('_', '-')}#${r.refId}'
: '',
r.amount,
r.balance,
r.status == 'open' ? '未结清' : '已结清',
r.remark ?? '',
])
.toList(),
);
},
icon: const Icon(Icons.download, size: 16),
@@ -528,7 +549,8 @@ class _AddPaymentDialogState extends State<_AddPaymentDialog> {
final amount = double.tryParse(_amountCtrl.text.trim());
if (amount == null || amount <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入有效金额'), backgroundColor: AppTheme.danger),
const SnackBar(
content: Text('请输入有效金额'), backgroundColor: AppTheme.danger),
);
return;
}
@@ -537,15 +559,17 @@ class _AddPaymentDialogState extends State<_AddPaymentDialog> {
final body = <String, dynamic>{
'type': _type,
'amount': amount,
'record_date': '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(),
'record_date': formatYmd(_date),
if (_remarkCtrl.text.trim().isNotEmpty)
'remark': _remarkCtrl.text.trim(),
};
await widget.repo.create(body);
if (mounted) {
Navigator.of(context).pop();
widget.onSaved();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('添加成功'), backgroundColor: AppTheme.success),
const SnackBar(
content: Text('添加成功'), backgroundColor: AppTheme.success),
);
}
} catch (e) {
@@ -570,27 +594,19 @@ class _AddPaymentDialogState extends State<_AddPaymentDialog> {
children: [
TextField(
controller: _amountCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: '金额', prefixText: '¥ '),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration:
const InputDecoration(labelText: '金额', prefixText: '¥ '),
),
const SizedBox(height: 12),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 30)),
);
if (picked != null) setState(() => _date = picked);
DatePickerField(
label: '日期',
value: formatYmd(_date),
onChanged: (v) {
final d = parseYmd(v);
if (d != null) setState(() => _date = d);
},
child: InputDecorator(
decoration: const InputDecoration(labelText: '日期'),
child: Text(
'${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 14),
),
),
),
const SizedBox(height: 12),
TextField(
@@ -609,8 +625,10 @@ class _AddPaymentDialogState extends State<_AddPaymentDialog> {
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('保存'),
),
],
@@ -678,7 +696,8 @@ class _TypeBadge extends StatelessWidget {
decoration:
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)),
child: Text(label,
style: TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
style:
TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
);
}
}
@@ -701,43 +720,41 @@ class _SummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final card = Container(
height: 72,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: AppTheme.border, width: 0.5),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 22),
height: 72,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: AppTheme.border, width: 0.5),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
const SizedBox(height: 4),
Text(value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: color)),
],
),
],
),
);
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary)),
const SizedBox(height: 4),
Text(value,
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w700, color: color)),
],
),
],
),
);
return width != null
? SizedBox(width: width, child: card)
: Expanded(child: card);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,9 @@ import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart';
import '../../core/auth/auth_state.dart';
import '../../core/utils/print_util.dart';
import '../../core/utils/date_util.dart';
import '../../models/stock_out.dart';
import '../../widgets/date_picker_field.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/partner_provider.dart';
import '../../providers/stock_out_provider.dart';
@@ -92,7 +94,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Future<void> _loadEditOrder() async {
setState(() => _loadingEdit = true);
try {
final order = await ref.read(stockOutRepositoryProvider).get(widget.editOrderId!);
final order =
await ref.read(stockOutRepositoryProvider).get(widget.editOrderId!);
setState(() {
_loadedOrder = order;
@@ -182,7 +185,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
setState(() {
_inventoryPickerItems = productMap.values.toList();
_inventoryMap = {
for (final item in _inventoryPickerItems) item.productId: item.availableQty
for (final item in _inventoryPickerItems)
item.productId: item.availableQty
};
});
} catch (_) {}
@@ -191,7 +195,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Future<void> _addItem() async {
if (_warehouseId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请先选择出库仓库'), backgroundColor: AppTheme.danger),
const SnackBar(
content: Text('请先选择出库仓库'), backgroundColor: AppTheme.danger),
);
return;
}
@@ -231,18 +236,20 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
if (!asDraft && !_formKey.currentState!.validate()) return;
if (_warehouseId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请选择出库仓库'), backgroundColor: AppTheme.danger),
const SnackBar(
content: Text('请选择出库仓库'), backgroundColor: AppTheme.danger),
);
return;
}
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请添加商品明细'), backgroundColor: AppTheme.danger),
const SnackBar(
content: Text('请添加商品明细'), backgroundColor: AppTheme.danger),
);
return;
}
final invalidQtyIndex = _items.indexWhere(
(item) => (double.tryParse(item.qtyCtrl.text) ?? 0) <= 0);
final invalidQtyIndex = _items
.indexWhere((item) => (double.tryParse(item.qtyCtrl.text) ?? 0) <= 0);
if (invalidQtyIndex >= 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -269,8 +276,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
final data = {
'warehouse_id': _warehouseId,
if (_partnerId != null) 'partner_id': _partnerId,
'order_date':
'${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}',
'order_date': formatYmd(_orderDate),
if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(),
'items': itemsData,
'status': asDraft ? 'draft' : 'pending',
@@ -278,7 +284,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
try {
if (_isEdit) {
await ref.read(stockOutRepositoryProvider).update(widget.editOrderId!, data);
await ref
.read(stockOutRepositoryProvider)
.update(widget.editOrderId!, data);
ref.read(stockOutListProvider.notifier).reload();
} else {
await ref.read(stockOutListProvider.notifier).createOrder(data);
@@ -303,6 +311,52 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
}
}
/// 是否有值得保留的录入(用于退出前判断是否提示保存草稿)。
bool get _isDirty {
if (_remarkCtrl.text.trim().isNotEmpty) return true;
if (_warehouseId != null) return true;
if (_partnerId != null) return true;
for (final item in _items) {
if (item.productId != null) return true;
}
return false;
}
/// 退出前处理:无录入直接离开;有未保存录入则提示「保存草稿 / 放弃 / 继续编辑」。
Future<void> _handleExit() async {
if (!_isDirty) {
context.go('/stock-out');
return;
}
final choice = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('未保存的内容'),
content:
Text(_isEdit ? '当前修改尚未保存。是否保存为草稿?' : '当前出库单尚未提交。是否保存为草稿,以免内容丢失?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop('edit'),
child: const Text('继续编辑'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop('discard'),
child: const Text('放弃', style: TextStyle(color: AppTheme.danger)),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop('draft'),
child: const Text('保存草稿'),
),
],
),
);
if (choice == 'draft') {
await _submit(true);
} else if (choice == 'discard' && mounted) {
context.go('/stock-out');
}
}
@override
Widget build(BuildContext context) {
final asyncWarehouses = ref.watch(warehouseListProvider);
@@ -310,320 +364,346 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
final currentUser = ref.watch(authStateProvider).user;
final isMobile = context.isMobile;
return Scaffold(
backgroundColor: AppTheme.background,
body: Column(
children: [
Container(
height: 52,
color: AppTheme.surface,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, size: 20),
onPressed: () => context.go('/stock-out'),
tooltip: '返回',
),
const SizedBox(width: 8),
Text(_isEdit ? '修改出库单' : '新建出库单',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
// 窄屏:仅保留主操作「提交」,其余收进溢出菜单,避免按钮挤爆顶栏
if (isMobile) ...[
ElevatedButton(
onPressed: _submitting ? null : () => _submit(false),
child: _submitting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('提交'),
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _handleExit();
},
child: Scaffold(
backgroundColor: AppTheme.background,
body: Column(
children: [
Container(
height: 52,
color: AppTheme.surface,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, size: 20),
onPressed: _handleExit,
tooltip: '返回',
),
PopupMenuButton<String>(
onSelected: (v) {
switch (v) {
case 'draft':
_submit(true);
break;
case 'print':
_printOrder();
break;
case 'cancel':
context.go('/stock-out');
break;
}
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'draft', child: Text('保存草稿')),
if (_isEdit && _loadedOrder != null)
const PopupMenuItem(value: 'print', child: Text('打印')),
const PopupMenuItem(value: 'cancel', child: Text('取消')),
const SizedBox(width: 8),
Text(_isEdit ? '修改出库单' : '新建出库单',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
// 窄屏:仅保留主操作「提交」,其余收进溢出菜单,避免按钮挤爆顶栏
if (isMobile) ...[
ElevatedButton(
onPressed: _submitting ? null : () => _submit(false),
child: _submitting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('提交'),
),
PopupMenuButton<String>(
onSelected: (v) {
switch (v) {
case 'draft':
_submit(true);
break;
case 'print':
_printOrder();
break;
case 'cancel':
_handleExit();
break;
}
},
itemBuilder: (_) => [
const PopupMenuItem(
value: 'draft', child: Text('保存草稿')),
if (_isEdit && _loadedOrder != null)
const PopupMenuItem(
value: 'print', child: Text('打印')),
const PopupMenuItem(value: 'cancel', child: Text('取消')),
],
),
] else ...[
if (_isEdit && _loadedOrder != null) ...[
OutlinedButton.icon(
onPressed: _printOrder,
icon: const Icon(Icons.print_outlined, size: 16),
label: const Text('打印'),
),
const SizedBox(width: 8),
],
),
] else ...[
if (_isEdit && _loadedOrder != null) ...[
OutlinedButton.icon(
onPressed: _printOrder,
icon: const Icon(Icons.print_outlined, size: 16),
label: const Text('打印'),
OutlinedButton(
onPressed: _submitting ? null : () => _submit(true),
child: const Text('保存草稿'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _submitting ? null : () => _submit(false),
icon: _submitting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Icon(Icons.send, size: 16),
label: const Text('提交审核'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _handleExit,
icon: const Icon(Icons.cancel_outlined, size: 16),
label: const Text('取消'),
),
],
OutlinedButton(
onPressed: _submitting ? null : () => _submit(true),
child: const Text('保存草稿'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _submitting ? null : () => _submit(false),
icon: _submitting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Icon(Icons.send, size: 16),
label: const Text('提交审核'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => context.go('/stock-out'),
icon: const Icon(Icons.cancel_outlined, size: 16),
label: const Text('取消'),
),
],
],
),
),
),
const Divider(height: 1),
if (_loadingEdit)
const Expanded(child: Center(child: CircularProgressIndicator())),
if (!_loadingEdit)
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('基本信息',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
_FormField(
label: '出库仓库',
required: true,
child: asyncWarehouses.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (warehouses) => DropdownButtonFormField<int>(
value: _warehouseId,
hint: const Text('请选择仓库',
style: TextStyle(fontSize: 13)),
items: warehouses
.map((w) => DropdownMenuItem(
value: w.id,
child: Text(w.name,
style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (v) {
setState(() => _warehouseId = v);
if (v != null) _loadInventory(v);
},
validator: (v) => v == null ? '不能为空' : null,
decoration: const InputDecoration(),
),
),
),
_FormField(
label: '客户',
child: asyncCustomers.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (result) => DropdownButtonFormField<int>(
value: _partnerId,
hint: const Text('请选择客户',
style: TextStyle(fontSize: 13)),
items: result.data
.map((p) => DropdownMenuItem(
value: p.id,
child: Text(p.name,
style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (v) => setState(() => _partnerId = v),
decoration: const InputDecoration(),
),
),
),
_FormField(
label: '出库员',
child: InputDecorator(
decoration: const InputDecoration(),
child: Text(
currentUser?.realName ?? '-',
style: const TextStyle(fontSize: 13),
),
),
),
_FormField(
label: '出库日期',
required: true,
child: InkWell(
onTap: _pickDate,
child: InputDecorator(
decoration: const InputDecoration(),
child: Row(
children: [
Expanded(
child: Text(
'${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 13),
),
),
const Icon(Icons.calendar_today,
size: 16, color: AppTheme.textSecondary),
],
const Divider(height: 1),
if (_loadingEdit)
const Expanded(child: Center(child: CircularProgressIndicator())),
if (!_loadingEdit)
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('基本信息',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
_FormField(
label: '出库仓库',
required: true,
child: asyncWarehouses.when(
loading: () =>
const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (warehouses) =>
DropdownButtonFormField<int>(
value: _warehouseId,
hint: const Text('请选择仓库',
style: TextStyle(fontSize: 13)),
items: warehouses
.map((w) => DropdownMenuItem(
value: w.id,
child: Text(w.name,
style: const TextStyle(
fontSize: 13))))
.toList(),
onChanged: (v) {
setState(() => _warehouseId = v);
if (v != null) _loadInventory(v);
},
validator: (v) =>
v == null ? '不能为空' : null,
decoration: const InputDecoration(),
),
),
),
),
],
),
const SizedBox(height: 16),
_FormField(
label: '备注',
width: double.infinity,
child: TextFormField(
controller: _remarkCtrl,
maxLines: 2,
decoration: const InputDecoration(
hintText: '选填,如有特殊说明请在此注明',
),
),
),
],
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('商品明细',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const Spacer(),
ElevatedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add, size: 16),
label: const Text('添加商品'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 32)),
),
],
),
const SizedBox(height: 12),
// 窄屏:逐项卡片竖排,避免 9 列表格横向溢出;宽屏保持表格
if (isMobile)
Column(
children: List.generate(
_items.length,
(i) => Padding(
padding:
const EdgeInsets.only(bottom: 10),
child: _buildItemCard(i),
)),
)
else
Table(
columnWidths: const {
0: FixedColumnWidth(36), // 序号
1: FlexColumnWidth(1.2), // 商品编码
2: FlexColumnWidth(2.0), // 商品名称
3: FlexColumnWidth(1.2), // 系列
4: FlexColumnWidth(1.2), // 规格
5: FlexColumnWidth(1.0), // 单价
6: FlexColumnWidth(0.8), // 数量
7: FlexColumnWidth(1.0), // 金额
8: FixedColumnWidth(48), // 操作
},
children: [
TableRow(
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
children: [
'序号', '商品编码', '商品名称', '系列', '规格', '单价', '数量', '金额', '操作',
]
.map((h) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 10),
child: Text(h,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
))
.toList(),
_FormField(
label: '客户',
child: asyncCustomers.when(
loading: () =>
const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (result) =>
DropdownButtonFormField<int>(
value: _partnerId,
hint: const Text('请选择客户',
style: TextStyle(fontSize: 13)),
items: result.data
.map((p) => DropdownMenuItem(
value: p.id,
child: Text(p.name,
style: const TextStyle(
fontSize: 13))))
.toList(),
onChanged: (v) =>
setState(() => _partnerId = v),
decoration: const InputDecoration(),
),
),
),
_FormField(
label: '出库员',
child: InputDecorator(
decoration: const InputDecoration(),
child: Text(
currentUser?.realName ?? '-',
style: const TextStyle(fontSize: 13),
),
),
),
_FormField(
label: '出库日期',
required: true,
width: 320,
child: DatePickerField(
value: formatYmd(_orderDate),
onChanged: (v) {
final d = parseYmd(v);
if (d != null)
setState(() => _orderDate = d);
},
),
),
...List.generate(_items.length, (i) => _buildItemRow(i)),
],
),
if (_items.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: Text('暂无商品,点击"添加商品"从库存中选择',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13))),
const SizedBox(height: 16),
_FormField(
label: '备注',
width: double.infinity,
child: TextFormField(
controller: _remarkCtrl,
maxLines: 2,
decoration: const InputDecoration(
hintText: '选填,如有特殊说明请在此注明',
),
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
],
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('合计金额:',
const Text('商品明细',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500)),
Text(
'¥${_totalAmount.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppTheme.danger),
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const Spacer(),
ElevatedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add, size: 16),
label: const Text('添加商品'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 32)),
),
],
),
),
],
const SizedBox(height: 12),
// 窄屏:逐项卡片竖排,避免 9 列表格横向溢出;宽屏保持表格
if (isMobile)
Column(
children: List.generate(
_items.length,
(i) => Padding(
padding: const EdgeInsets.only(
bottom: 10),
child: _buildItemCard(i),
)),
)
else
Table(
columnWidths: const {
0: FixedColumnWidth(36), // 序号
1: FlexColumnWidth(1.2), // 商品编码
2: FlexColumnWidth(2.0), // 商品名称
3: FlexColumnWidth(1.2), // 系列
4: FlexColumnWidth(1.2), // 规格
5: FlexColumnWidth(1.0), // 单价
6: FlexColumnWidth(0.8), // 数量
7: FlexColumnWidth(1.0), // 金额
8: FixedColumnWidth(48), // 操作
},
children: [
TableRow(
decoration: const BoxDecoration(
color: Color(0xFFF0F4FF)),
children: [
'序号',
'商品编码',
'商品名称',
'系列',
'规格',
'单价',
'数量',
'金额',
'操作',
]
.map((h) => Padding(
padding: const EdgeInsets
.symmetric(
horizontal: 8,
vertical: 10),
child: Text(h,
style: const TextStyle(
fontSize: 13,
fontWeight:
FontWeight.w600,
color: AppTheme
.primaryDark)),
))
.toList(),
),
...List.generate(_items.length,
(i) => _buildItemRow(i)),
],
),
if (_items.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text('暂无商品,点击"添加商品"从库存中选择',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 13))),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text('合计金额:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500)),
Text(
'¥${_totalAmount.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppTheme.danger),
),
],
),
),
],
),
),
),
),
],
],
),
),
),
),
),
],
],
),
),
);
}
@@ -640,26 +720,39 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
),
children: [
// 序号
_cell(Text('${index + 1}', style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary))),
_cell(Text('${index + 1}',
style:
const TextStyle(fontSize: 13, color: AppTheme.textSecondary))),
// 商品编码
_cell(Text(item.productCode, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
_cell(Text(item.productCode,
style:
const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
// 商品名称
_cell(Text(item.productName, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)),
_cell(Text(item.productName,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis)),
// 系列
_cell(Text(item.series, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
_cell(Text(item.series,
style:
const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
// 规格
_cell(Text(item.spec, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
_cell(Text(item.spec,
style:
const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
// 单价
_cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-', style: const TextStyle(fontSize: 13))),
_cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-',
style: const TextStyle(fontSize: 13))),
// 数量
Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)),
// 金额
_cell(Text('¥${amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))),
_cell(Text('¥${amount.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))),
// 操作
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: IconButton(
icon: const Icon(Icons.delete_outline, size: 18, color: AppTheme.danger),
icon: const Icon(Icons.delete_outline,
size: 18, color: AppTheme.danger),
onPressed: () => _removeItem(index),
tooltip: '删除',
padding: EdgeInsets.zero,
@@ -676,7 +769,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
decoration: const InputDecoration(hintText: '0', isDense: true),
style: const TextStyle(fontSize: 13),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
],
onChanged: (_) => setState(() {}),
validator: (v) {
if (v == null || v.isEmpty) return '不能为空';
@@ -695,9 +790,11 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
return MobileListCard(
title: Text(item.productName),
subtitle: item.productCode.isNotEmpty ? Text('编码 ${item.productCode}') : null,
subtitle:
item.productCode.isNotEmpty ? Text('编码 ${item.productCode}') : null,
trailing: IconButton(
icon: const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger),
icon:
const Icon(Icons.delete_outline, size: 20, color: AppTheme.danger),
onPressed: () => _removeItem(index),
tooltip: '删除',
visualDensity: VisualDensity.compact,
@@ -719,7 +816,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Widget _buildInventoryCell(int? productId, [double? available]) {
if (productId == null) {
return _cell(const Text('-', style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)));
return _cell(const Text('-',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)));
}
final qty = available ?? _inventoryMap[productId];
final text = qty != null
@@ -730,18 +828,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
: qty <= 0
? AppTheme.danger
: AppTheme.primary;
return _cell(Text(text, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color)));
}
Future<void> _pickDate() async {
final date = await showDatePicker(
context: context,
initialDate: _orderDate,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
locale: const Locale('zh', 'CN'),
);
if (date != null) setState(() => _orderDate = date);
return _cell(Text(text,
style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w700, color: color)));
}
}
@@ -763,9 +852,7 @@ class _FormField extends StatelessWidget {
// 窄屏(手机)字段占满整行,便于点选;宽屏沿用固定宽度配合 Wrap 多列。
final effectiveWidth = width == double.infinity
? double.infinity
: (context.isMobile
? MediaQuery.sizeOf(context).width - 64
: width);
: (context.isMobile ? MediaQuery.sizeOf(context).width - 64 : width);
return SizedBox(
width: effectiveWidth,
child: Column(
@@ -774,9 +861,11 @@ class _FormField extends StatelessWidget {
Row(
children: [
if (required)
const Text('*', style: TextStyle(color: AppTheme.danger, fontSize: 13)),
const Text('*',
style: TextStyle(color: AppTheme.danger, fontSize: 13)),
Text(label,
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
],
),
const SizedBox(height: 6),
@@ -820,7 +909,8 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
@override
Widget build(BuildContext context) {
final filtered = _filtered;
final allSelected = filtered.isNotEmpty && filtered.every((e) => _selected.contains(e.productId));
final allSelected = filtered.isNotEmpty &&
filtered.every((e) => _selected.contains(e.productId));
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
@@ -835,16 +925,19 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
child: Row(
children: [
const Text('选择商品',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
style:
TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text('已选 ${_selected.length}',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
constraints:
const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
),
@@ -859,7 +952,8 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
hintText: '搜索商品编码、名称或系列',
prefixIcon: Icon(Icons.search, size: 18),
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
onChanged: (v) => setState(() => _search = v),
),
@@ -978,7 +1072,8 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
? null
: () {
final result = widget.items
.where((item) => _selected.contains(item.productId))
.where((item) =>
_selected.contains(item.productId))
.toList();
Navigator.pop(context, result);
},
@@ -1002,7 +1097,8 @@ class _InventoryPickerDialogState extends State<_InventoryPickerDialog> {
color: AppTheme.primaryDark)),
);
Widget _dataCell(String text, double width, {Color? color, bool bold = false}) =>
Widget _dataCell(String text, double width,
{Color? color, bool bold = false}) =>
SizedBox(
width: width,
child: Text(