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);