feat(client): 全模块 API 对接 + 修复登陆跳转 + 菜单 UI 优化

API 对接:
- 入库/出库/库存/财务/往来单位/基础数据全部对接后端 REST API
- 新增 repositories、providers、models 层,统一分层架构
- auth 从 flutter_secure_storage 迁移到 shared_preferences

登陆跳转修复:
- 将 _RouterNotifier 提取为独立 Riverpod provider,appRouterProvider
  使用 ref.read 避免依赖链导致 router 重建后跳回 /login
- redirect 函数新增 initialized 守卫,防止 auth 未恢复时误重定向
- 添加调试日志(Router/Auth/ApiClient)定位 401 触发的 logout 链路

退出菜单 UI:
- 去掉 ListTile,改用 Row + 自定义 padding,文字左对齐
- MouseRegion + AnimatedContainer 实现 hover 高亮(普通项蓝底/退出红底)
- 菜单圆角 6px,elevation 8,分割线高度 1px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-07 22:20:28 +08:00
parent 31ea370cea
commit d717fb3735
70 changed files with 6919 additions and 2580 deletions
@@ -3,6 +3,11 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/product.dart';
import '../../providers/partner_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/stock_in_provider.dart';
import '../../providers/warehouse_provider.dart';
class StockInFormScreen extends ConsumerStatefulWidget {
const StockInFormScreen({super.key});
@@ -13,37 +18,25 @@ class StockInFormScreen extends ConsumerStatefulWidget {
class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
final _formKey = GlobalKey<FormState>();
final _orderNoCtrl = TextEditingController(text: 'RK20260404004');
final _remarkCtrl = TextEditingController();
String _supplier = '贵州茅台酒股份有限公司';
String _warehouse = '主仓库';
int? _warehouseId;
int? _partnerId;
DateTime _orderDate = DateTime.now();
bool _submitting = false;
final List<Map<String, dynamic>> _items = [
{
'name': '茅台酒(飞天)53度500ml',
'spec': '500ml/瓶',
'unit': '',
'qty': TextEditingController(text: '10'),
'price': TextEditingController(text: '2600.00'),
},
{
'name': '五粮液(普五)52度500ml',
'spec': '500ml/瓶',
'unit': '',
'qty': TextEditingController(text: '20'),
'price': TextEditingController(text: '1050.00'),
},
];
final List<_ItemRow> _items = [];
@override
void initState() {
super.initState();
_items.add(_ItemRow());
}
@override
void dispose() {
_orderNoCtrl.dispose();
_remarkCtrl.dispose();
for (final item in _items) {
(item['qty'] as TextEditingController).dispose();
(item['price'] as TextEditingController).dispose();
item.dispose();
}
super.dispose();
}
@@ -51,60 +44,94 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
double get _totalAmount {
double total = 0;
for (final item in _items) {
final qty = double.tryParse(
(item['qty'] as TextEditingController).text) ??
0;
final price = double.tryParse(
(item['price'] as TextEditingController).text) ??
0;
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = double.tryParse(item.priceCtrl.text) ?? 0;
total += qty * price;
}
return total;
}
void _addItem() {
setState(() {
_items.add({
'name': '',
'spec': '',
'unit': '',
'qty': TextEditingController(),
'price': TextEditingController(),
});
});
setState(() => _items.add(_ItemRow()));
}
void _removeItem(int index) {
setState(() {
final item = _items.removeAt(index);
(item['qty'] as TextEditingController).dispose();
(item['price'] as TextEditingController).dispose();
_items[index].dispose();
_items.removeAt(index);
});
}
Future<void> _submit(bool asDraft) async {
if (!asDraft && !_formKey.currentState!.validate()) return;
setState(() => _submitting = true);
await Future.delayed(const Duration(milliseconds: 600));
if (mounted) {
if (_warehouseId == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(asDraft ? '已保存为草稿' : '入库单已提交审核'),
backgroundColor: AppTheme.success,
),
const SnackBar(content: Text('请选择入库仓库'), backgroundColor: AppTheme.danger),
);
context.go('/stock-in');
return;
}
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请添加商品明细'), backgroundColor: AppTheme.danger),
);
return;
}
setState(() => _submitting = true);
final itemsData = _items.map((item) {
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = double.tryParse(item.priceCtrl.text) ?? 0;
return {
'product_id': item.productId ?? 0,
'quantity': qty,
'unit_price': price,
'total_price': qty * price,
};
}).toList();
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')}',
if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(),
'items': itemsData,
'status': asDraft ? 'draft' : 'pending',
};
try {
await ref.read(stockInListProvider.notifier).createOrder(data);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(asDraft ? '已保存为草稿' : '入库单已提交审核'),
backgroundColor: AppTheme.success,
),
);
context.go('/stock-in');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('操作失败:$e'), backgroundColor: AppTheme.danger),
);
}
} finally {
if (mounted) setState(() => _submitting = false);
}
if (mounted) setState(() => _submitting = false);
}
@override
Widget build(BuildContext context) {
final asyncWarehouses = ref.watch(warehouseListProvider);
final asyncSuppliers = ref.watch(supplierListProvider);
return Scaffold(
backgroundColor: AppTheme.background,
body: Column(
children: [
// Page header
Container(
height: 52,
color: AppTheme.surface,
@@ -155,7 +182,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic info card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -172,71 +198,70 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
spacing: 16,
runSpacing: 16,
children: [
_FormField(
label: '入库单号',
child: TextFormField(
controller: _orderNoCtrl,
readOnly: true,
style: const TextStyle(
fontFamily: 'monospace'),
decoration: const InputDecoration(
suffixIcon: Icon(
Icons.autorenew,
size: 16),
),
),
),
_FormField(
label: '供应商',
required: true,
child: DropdownButtonFormField<String>(
value: _supplier,
items: [
'贵州茅台酒股份有限公司',
'四川五粮液股份有限公司',
'江苏洋河酒厂股份有限公司',
'剑南春(集团)有限责任公司',
'泸州老窖股份有限公司',
'山西汾酒股份有限公司',
]
.map((s) => DropdownMenuItem(
value: s,
child: Text(s,
style: const TextStyle(
fontSize: 13))))
.toList(),
onChanged: (v) =>
setState(() => _supplier = v!),
validator: (v) => v == null || v.isEmpty
? '请选择供应商'
: null,
decoration: const InputDecoration(),
),
),
// Warehouse dropdown
_FormField(
label: '入库仓库',
required: true,
child: DropdownButtonFormField<String>(
value: _warehouse,
items: ['主仓库', '副仓库', '保税仓库']
.map((s) => DropdownMenuItem(
value: s,
child: Text(s,
style: const TextStyle(
fontSize: 13))))
.toList(),
onChanged: (v) =>
setState(() => _warehouse = v!),
decoration: const InputDecoration(),
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),
validator: (v) =>
v == null ? '不能为空' : null,
decoration: const InputDecoration(),
),
),
),
// Partner dropdown
_FormField(
label: '供应商',
child: asyncSuppliers.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(),
),
),
),
// Date picker
_FormField(
label: '入库日期',
required: true,
child: InkWell(
onTap: _pickDate,
child: InputDecorator(
decoration: const InputDecoration(),
decoration:
const InputDecoration(),
child: Row(
children: [
Expanded(
@@ -249,7 +274,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
const Icon(
Icons.calendar_today,
size: 16,
color: AppTheme.textSecondary),
color:
AppTheme.textSecondary),
],
),
),
@@ -274,7 +300,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
),
),
const SizedBox(height: 12),
// Items card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -299,17 +324,14 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
],
),
const SizedBox(height: 12),
// Items table
Table(
columnWidths: const {
0: FixedColumnWidth(36),
1: FlexColumnWidth(3),
2: FlexColumnWidth(2),
3: FixedColumnWidth(60),
2: FlexColumnWidth(1.5),
3: FlexColumnWidth(1.5),
4: FlexColumnWidth(1.5),
5: FlexColumnWidth(1.5),
6: FlexColumnWidth(1.5),
7: FixedColumnWidth(60),
5: FixedColumnWidth(60),
},
children: [
TableRow(
@@ -317,23 +339,24 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
color: Color(0xFFF0F4FF)),
children: [
'序号',
'商品名称',
'规格',
'单位',
'商品',
'数量',
'单价',
'金额',
'操作',
]
.map((h) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 10),
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 10),
child: Text(h,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color:
AppTheme.primaryDark)),
fontWeight:
FontWeight.w600,
color: AppTheme
.primaryDark)),
))
.toList(),
),
@@ -343,7 +366,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
],
),
const Divider(height: 1),
// Total
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
@@ -379,10 +401,9 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
TableRow _buildItemRow(int index) {
final item = _items[index];
final qtyCtrl = item['qty'] as TextEditingController;
final priceCtrl = item['price'] as TextEditingController;
final qty = double.tryParse(qtyCtrl.text) ?? 0;
final price = double.tryParse(priceCtrl.text) ?? 0;
final asyncProducts = ref.watch(productListProvider);
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = double.tryParse(item.priceCtrl.text) ?? 0;
final amount = qty * price;
return TableRow(
@@ -390,90 +411,89 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
color: index.isEven ? Colors.white : const Color(0xFFFAFAFA),
),
children: [
// Index
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Text('${index + 1}',
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
),
// Name
Padding(
padding: const EdgeInsets.all(4),
child: asyncProducts.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (result) => DropdownButtonFormField<int>(
value: item.productId,
hint: const Text('选择商品',
style: TextStyle(fontSize: 13)),
items: result.data
.map((p) => DropdownMenuItem(
value: p.id,
child: Text('${p.name}${p.spec ?? p.unit}',
style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (v) {
setState(() {
item.productId = v;
// Auto-fill price if available
if (v != null) {
final found = result.data.firstWhere(
(p) => p.id == v,
orElse: () => Product(
id: 0, code: '', name: '', unit: ''));
if (found.purchasePrice != null) {
item.priceCtrl.text =
found.purchasePrice!.toStringAsFixed(2);
}
}
});
},
validator: (v) => v == null ? '不能为空' : null,
decoration: const InputDecoration(),
),
),
),
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
initialValue: item['name'] as String,
decoration:
const InputDecoration(hintText: '商品名称'),
style: const TextStyle(fontSize: 13),
onChanged: (v) => item['name'] = v,
validator: (v) =>
(v == null || v.isEmpty) ? '必填' : null,
),
),
// Spec
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
initialValue: item['spec'] as String,
decoration: const InputDecoration(hintText: '规格'),
style: const TextStyle(fontSize: 13),
onChanged: (v) => item['spec'] = v,
),
),
// Unit
Padding(
padding: const EdgeInsets.all(4),
child: DropdownButtonFormField<String>(
value: item['unit'] as String,
items: ['', '', '', '', '']
.map((u) => DropdownMenuItem(
value: u,
child: Text(u, style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (v) => setState(() => item['unit'] = v!),
decoration: const InputDecoration(),
),
),
// Qty
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
controller: qtyCtrl,
controller: item.qtyCtrl,
decoration: const InputDecoration(hintText: '0'),
style: const TextStyle(fontSize: 13),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'))
],
onChanged: (_) => setState(() {}),
validator: (v) {
if (v == null || v.isEmpty) return '必填';
if (v == null || v.isEmpty) return '不能为空';
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
return null;
},
),
),
// Price
Padding(
padding: const EdgeInsets.all(4),
child: TextFormField(
controller: priceCtrl,
controller: item.priceCtrl,
decoration: const InputDecoration(
hintText: '0.00', prefixText: '¥'),
style: const TextStyle(fontSize: 13),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}'))
],
onChanged: (_) => setState(() {}),
validator: (v) {
if (v == null || v.isEmpty) return '必填';
if (v == null || v.isEmpty) return '不能为空';
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
return null;
},
),
),
// Amount
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Text(
@@ -482,13 +502,13 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
fontSize: 13, fontWeight: FontWeight.w500),
),
),
// Actions
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: IconButton(
icon: const Icon(Icons.delete_outline,
size: 18, color: AppTheme.danger),
onPressed: _items.length > 1 ? () => _removeItem(index) : null,
onPressed:
_items.length > 1 ? () => _removeItem(index) : null,
tooltip: '删除',
padding: EdgeInsets.zero,
constraints:
@@ -510,6 +530,17 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
}
}
class _ItemRow {
int? productId;
final TextEditingController qtyCtrl = TextEditingController();
final TextEditingController priceCtrl = TextEditingController();
void dispose() {
qtyCtrl.dispose();
priceCtrl.dispose();
}
}
class _FormField extends StatelessWidget {
final String label;
final Widget child;
@@ -534,7 +565,8 @@ class _FormField extends StatelessWidget {
children: [
if (required)
const Text('*',
style: TextStyle(color: AppTheme.danger, fontSize: 13)),
style:
TextStyle(color: AppTheme.danger, fontSize: 13)),
Text(label,
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
@@ -1,144 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../widgets/page_scaffold.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/status_badge.dart';
import '../../core/theme/app_theme.dart';
import '../../models/stock_in.dart';
import '../../providers/stock_in_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart';
class StockInListScreen extends ConsumerStatefulWidget {
const StockInListScreen({super.key});
@override
ConsumerState<StockInListScreen> createState() =>
_StockInListScreenState();
ConsumerState<StockInListScreen> createState() => _StockInListScreenState();
}
class _StockInListScreenState extends ConsumerState<StockInListScreen> {
int _page = 1;
final _searchCtrl = TextEditingController();
String _statusFilter = '全部';
String _statusFilter = '';
DateTimeRange? _dateRange;
final List<Map<String, dynamic>> _mockOrders = [
{
'no': 'RK20260401001',
'supplier': '贵州茅台酒股份有限公司',
'warehouse': '主仓库',
'amount': '85000.00',
'status': OrderStatus.approved,
'creator': '张三',
'date': '2026-04-01',
},
{
'no': 'RK20260401002',
'supplier': '四川五粮液股份有限公司',
'warehouse': '副仓库',
'amount': '42500.00',
'status': OrderStatus.pending,
'creator': '李四',
'date': '2026-04-01',
},
{
'no': 'RK20260402001',
'supplier': '江苏洋河酒厂股份有限公司',
'warehouse': '主仓库',
'amount': '36800.00',
'status': OrderStatus.approved,
'creator': '王五',
'date': '2026-04-02',
},
{
'no': 'RK20260402002',
'supplier': '剑南春(集团)有限责任公司',
'warehouse': '主仓库',
'amount': '28600.00',
'status': OrderStatus.draft,
'creator': '张三',
'date': '2026-04-02',
},
{
'no': 'RK20260403001',
'supplier': '泸州老窖股份有限公司',
'warehouse': '副仓库',
'amount': '55200.00',
'status': OrderStatus.approved,
'creator': '李四',
'date': '2026-04-03',
},
{
'no': 'RK20260403002',
'supplier': '古井贡酒股份有限公司',
'warehouse': '主仓库',
'amount': '19800.00',
'status': OrderStatus.rejected,
'creator': '王五',
'date': '2026-04-03',
},
{
'no': 'RK20260403003',
'supplier': '贵州茅台酒股份有限公司',
'warehouse': '主仓库',
'amount': '126000.00',
'status': OrderStatus.pending,
'creator': '张三',
'date': '2026-04-03',
},
{
'no': 'RK20260404001',
'supplier': '山西汾酒股份有限公司',
'warehouse': '主仓库',
'amount': '33600.00',
'status': OrderStatus.approved,
'creator': '李四',
'date': '2026-04-04',
},
{
'no': 'RK20260404002',
'supplier': '郎酒股份有限公司',
'warehouse': '副仓库',
'amount': '47300.00',
'status': OrderStatus.draft,
'creator': '王五',
'date': '2026-04-04',
},
{
'no': 'RK20260404003',
'supplier': '四川五粮液股份有限公司',
'warehouse': '主仓库',
'amount': '68500.00',
'status': OrderStatus.pending,
'creator': '张三',
'date': '2026-04-04',
},
];
String? get _startDate => _dateRange != null
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
: null;
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
String? get _endDate => _dateRange != null
? '${_dateRange!.end.year}-${_dateRange!.end.month.toString().padLeft(2, '0')}-${_dateRange!.end.day.toString().padLeft(2, '0')}'
: null;
List<Map<String, dynamic>> _filteredOrders({OrderStatus? forceStatus}) {
return _mockOrders.where((o) {
if (forceStatus != null && o['status'] != forceStatus) return false;
if (_statusFilter != '全部' && forceStatus == null) {
final map = {
'草稿': OrderStatus.draft,
'待审核': OrderStatus.pending,
'已审核': OrderStatus.approved,
'已拒绝': OrderStatus.rejected,
};
if (o['status'] != map[_statusFilter]) return false;
}
final q = _searchCtrl.text.toLowerCase();
if (q.isNotEmpty) {
final no = (o['no'] as String).toLowerCase();
final supplier = (o['supplier'] as String).toLowerCase();
if (!no.contains(q) && !supplier.contains(q)) return false;
}
return true;
}).toList();
OrderStatus _apiStatusToEnum(String status) {
switch (status) {
case 'pending':
return OrderStatus.pending;
case 'approved':
return OrderStatus.approved;
case 'rejected':
return OrderStatus.rejected;
default:
return OrderStatus.draft;
}
}
@override
@@ -147,23 +46,62 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
title: '入库管理',
tabs: const [
Tab(text: '入库单'),
Tab(text: '入库查询'),
Tab(text: '入库审核'),
],
tabViews: [
_buildOrderList(),
_buildQueryView(),
_buildOrderList(forceStatus: OrderStatus.pending),
_buildListTab(filterStatus: null),
_buildListTab(filterStatus: 'pending'),
],
);
}
Widget _buildOrderList({OrderStatus? forceStatus}) {
final orders = _filteredOrders(forceStatus: forceStatus);
Widget _buildListTab({String? filterStatus}) {
final asyncOrders = ref.watch(stockInListProvider);
return asyncOrders.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('加载失败:$e',
style: const TextStyle(color: AppTheme.danger)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>
ref.read(stockInListProvider.notifier).reload(),
child: const Text('重试'),
),
],
),
),
data: (result) {
// Client-side filter for review tab
final orders = filterStatus != null
? result.data
.where((o) => o.status == filterStatus)
.toList()
: result.data;
return _buildOrderTable(
orders: orders,
totalCount: filterStatus != null ? orders.length : result.total,
page: result.page,
showStatusFilter: filterStatus == null,
);
},
);
}
Widget _buildOrderTable({
required List<StockInOrder> orders,
required int totalCount,
required int page,
required bool showStatusFilter,
}) {
return DataTableCard(
totalCount: orders.length,
page: _page,
onPageChanged: (p) => setState(() => _page = p),
totalCount: totalCount,
page: page,
onPageChanged: (p) =>
ref.read(stockInListProvider.notifier).setPage(p),
toolbar: Row(
children: [
ElevatedButton.icon(
@@ -171,58 +109,41 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
icon: const Icon(Icons.add, size: 16),
label: const Text('新建入库单'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.file_upload_outlined, size: 16),
label: const Text('导入'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.file_download_outlined, size: 16),
label: const Text('导出'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.print_outlined, size: 16),
label: const Text('打印'),
),
const Spacer(),
if (forceStatus == null) ...[
if (showStatusFilter) ...[
_StatusFilterDropdown(
value: _statusFilter,
onChanged: (v) => setState(() {
_statusFilter = v!;
_page = 1;
}),
onChanged: (v) {
setState(() => _statusFilter = v ?? '');
ref
.read(stockInListProvider.notifier)
.setStatus(v ?? '');
},
),
const SizedBox(width: 8),
],
SizedBox(
width: 180,
child: TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
hintText: '搜索单号/供应商',
prefixIcon: Icon(Icons.search, size: 16),
hintStyle: TextStyle(fontSize: 13),
),
onChanged: (_) => setState(() => _page = 1),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _pickDateRange,
icon: const Icon(Icons.date_range, size: 16),
label: Text(
_dateRange == null
? '选择日期'
: '${_dateRange!.start.toString().substring(0, 10)} ~ ${_dateRange!.end.toString().substring(0, 10)}',
: '$_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);
},
),
],
],
),
columns: const [
@@ -231,119 +152,75 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
DataColumn(label: Text('仓库')),
DataColumn(label: Text('金额'), numeric: true),
DataColumn(label: Text('状态')),
DataColumn(label: Text('录入人')),
DataColumn(label: Text('日期')),
DataColumn(label: Text('操作')),
],
rows: orders
.map((o) => DataRow(cells: [
DataCell(Text(o['no'] as String,
style: const TextStyle(
color: AppTheme.primary,
fontFamily: 'monospace',
fontSize: 12))),
DataCell(Text(o['supplier'] as String)),
DataCell(Text(o['warehouse'] as String)),
DataCell(Text('¥${o['amount']}')),
DataCell(StatusBadge(o['status'] as OrderStatus)),
DataCell(Text(o['creator'] as String)),
DataCell(Text(o['date'] as String)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {},
child: const Text('查看',
style: TextStyle(fontSize: 12))),
if ((o['status'] as OrderStatus) ==
OrderStatus.pending)
TextButton(
onPressed: () {},
child: const Text('审核',
style: TextStyle(
color: AppTheme.success,
fontSize: 12))),
if ((o['status'] as OrderStatus) ==
OrderStatus.draft)
TextButton(
onPressed: () {},
child: const Text('编辑',
style: TextStyle(fontSize: 12))),
if ((o['status'] as OrderStatus) ==
OrderStatus.draft)
TextButton(
onPressed: () {},
child: const Text('删除',
style: TextStyle(
color: AppTheme.danger,
fontSize: 12))),
],
)),
]))
.toList(),
);
}
Widget _buildQueryView() {
return Column(
children: [
Container(
color: AppTheme.surface,
padding: const EdgeInsets.all(12),
child: Row(
children: [
const Text('查询条件:', style: TextStyle(fontSize: 13)),
const SizedBox(width: 8),
SizedBox(
width: 200,
child: TextField(
controller: _searchCtrl,
decoration: const InputDecoration(
hintText: '单号/供应商',
prefixIcon: Icon(Icons.search, size: 16),
hintStyle: TextStyle(fontSize: 13),
),
onChanged: (_) => setState(() => _page = 1),
),
),
const SizedBox(width: 8),
_StatusFilterDropdown(
value: _statusFilter,
onChanged: (v) => setState(() {
_statusFilter = v!;
_page = 1;
}),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _pickDateRange,
icon: const Icon(Icons.date_range, size: 16),
label: Text(
_dateRange == null ? '选择日期范围' : '${_dateRange!.start.toString().substring(0, 10)} ~ ${_dateRange!.end.toString().substring(0, 10)}',
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () => setState(() {}),
icon: const Icon(Icons.search, size: 16),
label: const Text('查询'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => setState(() {
_searchCtrl.clear();
_statusFilter = '全部';
_dateRange = null;
}),
child: const Text('重置'),
),
],
),
),
const Divider(height: 1),
Expanded(child: _buildOrderList()),
],
rows: orders.isEmpty
? [
const DataRow(cells: [
DataCell(SizedBox()),
DataCell(Text('暂无入库单',
style: TextStyle(color: AppTheme.textSecondary))),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
])
]
: orders
.map((o) => DataRow(
cells: [
DataCell(Text(o.orderNo,
style: const TextStyle(
color: AppTheme.primary,
fontFamily: 'monospace',
fontSize: 12))),
DataCell(Text(o.partnerName ?? '-')),
DataCell(Text(o.warehouseName ?? '-')),
DataCell(Text(o.totalAmount != null
? '¥${o.totalAmount!.toStringAsFixed(2)}'
: '-')),
DataCell(StatusBadge(
_apiStatusToEnum(o.status))),
DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
if (o.status == 'draft')
TextButton(
onPressed: () =>
_confirmSubmit(context, o),
child: const Text('提交',
style: TextStyle(
fontSize: 12,
color: AppTheme.primary)),
),
if (o.status == 'pending') ...[
TextButton(
key: Key('btn_approve_${o.id}'),
onPressed: () =>
_confirmApprove(context, o),
child: const Text('通过',
style: TextStyle(
fontSize: 12,
color: AppTheme.success)),
),
TextButton(
key: Key('btn_reject_${o.id}'),
onPressed: () =>
_confirmReject(context, o),
child: const Text('拒绝',
style: TextStyle(
fontSize: 12,
color: AppTheme.danger)),
),
],
],
)),
],
))
.toList(),
);
}
@@ -354,7 +231,126 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
lastDate: DateTime(2030),
initialDateRange: _dateRange,
);
if (range != null) setState(() => _dateRange = range);
if (range != null) {
setState(() => _dateRange = range);
ref
.read(stockInListProvider.notifier)
.setDateRange(_startDate, _endDate);
}
}
Future<void> _confirmSubmit(BuildContext context, StockInOrder o) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('提交审核'),
content: Text('确认提交入库单「${o.orderNo}」?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('提交')),
],
),
);
if (confirmed == true && mounted) {
try {
await ref
.read(stockInListProvider.notifier)
.submitOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已提交审核'),
backgroundColor: AppTheme.success));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('提交失败:$e'),
backgroundColor: AppTheme.danger));
}
}
}
}
Future<void> _confirmApprove(BuildContext context, StockInOrder o) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('审核确认'),
content: Text('确认审核通过入库单「${o.orderNo}」?审核后将入库并增加库存。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.success,
foregroundColor: Colors.white),
child: const Text('通过'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await ref
.read(stockInListProvider.notifier)
.approveOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('审核通过'), backgroundColor: AppTheme.success));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('审核失败:$e'),
backgroundColor: AppTheme.danger));
}
}
}
}
Future<void> _confirmReject(BuildContext context, StockInOrder o) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('拒绝确认'),
content: Text('确认拒绝入库单「${o.orderNo}」?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消')),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger,
foregroundColor: Colors.white),
child: const Text('拒绝'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await ref
.read(stockInListProvider.notifier)
.rejectOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已拒绝'), backgroundColor: AppTheme.accent));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('操作失败:$e'),
backgroundColor: AppTheme.danger));
}
}
}
}
}
@@ -379,15 +375,16 @@ class _StatusFilterDropdown extends StatelessWidget {
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
items: ['全部', '草稿', '待审核', '已审核', '已拒绝']
.map((s) => DropdownMenuItem(
value: s,
child: Text(s, style: const TextStyle(fontSize: 13))))
.toList(),
value: value.isEmpty ? '' : value,
items: [
const DropdownMenuItem(value: '', child: Text('全部状态', style: TextStyle(fontSize: 13))),
const DropdownMenuItem(value: 'draft', child: Text('草稿', style: TextStyle(fontSize: 13))),
const DropdownMenuItem(value: 'pending', child: Text('待审核', style: TextStyle(fontSize: 13))),
const DropdownMenuItem(value: 'approved', child: Text('已审核', style: TextStyle(fontSize: 13))),
const DropdownMenuItem(value: 'rejected', child: Text('已拒绝', style: TextStyle(fontSize: 13))),
],
onChanged: onChanged,
style: const TextStyle(
fontSize: 13, color: AppTheme.textPrimary),
style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary),
),
),
);