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
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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_out.dart';
import '../../providers/stock_out_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart';
class StockOutListScreen extends ConsumerStatefulWidget {
const StockOutListScreen({super.key});
@@ -14,128 +16,28 @@ class StockOutListScreen extends ConsumerStatefulWidget {
}
class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
int _page = 1;
final _searchCtrl = TextEditingController();
String _statusFilter = '全部';
String _typeFilter = '全部';
String _statusFilter = '';
DateTimeRange? _dateRange;
final List<Map<String, dynamic>> _mockOrders = [
{
'no': 'CK20260401001',
'department': '餐饮部',
'purpose': '宴会用酒',
'warehouse': '主仓库',
'amount': '15600.00',
'status': OrderStatus.approved,
'creator': '李四',
'date': '2026-04-01',
'type': '领用',
},
{
'no': 'CK20260401002',
'department': '客房部',
'purpose': '客房迷你吧补货',
'warehouse': '主仓库',
'amount': '8200.00',
'status': OrderStatus.approved,
'creator': '王五',
'date': '2026-04-01',
'type': '领用',
},
{
'no': 'CK20260402001',
'department': '行政部',
'purpose': '接待用酒',
'warehouse': '副仓库',
'amount': '32000.00',
'status': OrderStatus.pending,
'creator': '张三',
'date': '2026-04-02',
'type': '调拨',
},
{
'no': 'CK20260402002',
'department': '餐饮部',
'purpose': '婚宴用酒',
'warehouse': '主仓库',
'amount': '68500.00',
'status': OrderStatus.approved,
'creator': '李四',
'date': '2026-04-02',
'type': '领用',
},
{
'no': 'CK20260403001',
'department': '采购部',
'purpose': '退货',
'warehouse': '副仓库',
'amount': '12000.00',
'status': OrderStatus.pending,
'creator': '王五',
'date': '2026-04-03',
'type': '退货',
},
{
'no': 'CK20260403002',
'department': '餐饮部',
'purpose': '散客用酒',
'warehouse': '主仓库',
'amount': '5400.00',
'status': OrderStatus.draft,
'creator': '张三',
'date': '2026-04-03',
'type': '领用',
},
{
'no': 'CK20260404001',
'department': '客房部',
'purpose': 'VIP客房补充',
'warehouse': '主仓库',
'amount': '18900.00',
'status': OrderStatus.approved,
'creator': '李四',
'date': '2026-04-04',
'type': '领用',
},
{
'no': 'CK20260404002',
'department': '行政部',
'purpose': '年度总结宴',
'warehouse': '主仓库',
'amount': '45000.00',
'status': OrderStatus.pending,
'creator': '王五',
'date': '2026-04-04',
'type': '领用',
},
];
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>> get _filtered {
return _mockOrders.where((o) {
if (_typeFilter != '全部' && o['type'] != _typeFilter) return false;
if (_statusFilter != '全部') {
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 dept = (o['department'] as String).toLowerCase();
if (!no.contains(q) && !dept.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
@@ -144,28 +46,61 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
title: '出库管理',
tabs: const [
Tab(text: '出库单'),
Tab(text: '出库查询'),
Tab(text: '出库审核'),
],
tabViews: [
_buildList(),
_buildList(),
_buildList(forceStatus: OrderStatus.pending),
_buildListTab(filterStatus: null),
_buildListTab(filterStatus: 'pending'),
],
);
}
Widget _buildList({OrderStatus? forceStatus}) {
final orders = forceStatus != null
? _mockOrders
.where((o) => o['status'] == forceStatus)
.toList()
: _filtered;
Widget _buildListTab({String? filterStatus}) {
final asyncOrders = ref.watch(stockOutListProvider);
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(stockOutListProvider.notifier).reload(),
child: const Text('重试'),
),
],
),
),
data: (result) {
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<StockOutOrder> 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(stockOutListProvider.notifier).setPage(p),
toolbar: Row(
children: [
ElevatedButton.icon(
@@ -173,110 +108,136 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
icon: const Icon(Icons.add, 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(),
// Type filter
_DropdownFilter(
value: _typeFilter,
items: ['全部', '领用', '调拨', '退货'],
onChanged: (v) => setState(() => _typeFilter = v!),
hint: '出库类型',
),
const SizedBox(width: 8),
if (forceStatus == null)
_DropdownFilter(
if (showStatusFilter) ...[
_StatusFilterDropdown(
value: _statusFilter,
items: ['全部', '草稿', '待审核', '已审核', '已拒绝'],
onChanged: (v) => setState(() => _statusFilter = v!),
hint: '状态',
onChanged: (v) {
setState(() => _statusFilter = v ?? '');
ref
.read(stockOutListProvider.notifier)
.setStatus(v ?? '');
},
),
if (forceStatus == null) 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
? '选择日期'
: '$_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);
},
),
],
],
),
columns: const [
DataColumn(label: Text('出库单号')),
DataColumn(label: Text('出库类型')),
DataColumn(label: Text('领用部门')),
DataColumn(label: Text('用途')),
DataColumn(label: Text('客户/往来单位')),
DataColumn(label: Text('仓库')),
DataColumn(label: Text('金额'), numeric: true),
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(_TypeBadge(o['type'] as String)),
DataCell(Text(o['department'] as String)),
DataCell(Text(o['purpose'] as String,
overflow: TextOverflow.ellipsis)),
DataCell(Text(o['warehouse'] as String)),
DataCell(Text('¥${o['amount']}')),
DataCell(StatusBadge(o['status'] as OrderStatus)),
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))),
TextButton(
onPressed: () {},
child: const Text('删除',
style: TextStyle(
color: AppTheme.danger,
fontSize: 12))),
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(),
))
.toList(),
);
}
Future<void> _pickDateRange() async {
final range = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
initialDateRange: _dateRange,
);
if (range != null) {
setState(() => _dateRange = range);
ref
.read(stockOutListProvider.notifier)
.setDateRange(_startDate, _endDate);
}
}
void _showCreateDialog(BuildContext context) {
showDialog(
context: context,
@@ -284,63 +245,144 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
title: const Text('新建出库单'),
content: const SizedBox(
width: 400,
child: Text('出库单创建功能完整实现请参考入库单流程。'),
child: Text('出库单完整创建功能请参考入库单流程。'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('关闭')),
child: const Text('取消')),
],
),
);
}
}
class _TypeBadge extends StatelessWidget {
final String type;
const _TypeBadge(this.type);
@override
Widget build(BuildContext context) {
final Color color;
switch (type) {
case '领用':
color = AppTheme.primary;
break;
case '调拨':
color = AppTheme.accent;
break;
case '退货':
color = AppTheme.danger;
break;
default:
color = AppTheme.textSecondary;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(3),
border: Border.all(color: color.withOpacity(0.3)),
Future<void> _confirmSubmit(
BuildContext context, StockOutOrder 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('提交')),
],
),
child: Text(type,
style: TextStyle(
color: color, fontSize: 12, fontWeight: FontWeight.w500)),
);
if (confirmed == true && mounted) {
try {
await ref
.read(stockOutListProvider.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, StockOutOrder 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(stockOutListProvider.notifier)
.approveOrder(o.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('审核通过'), backgroundColor: AppTheme.success));
}
} catch (e) {
// Show inventory-shortage error clearly
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('审核失败:$e'),
backgroundColor: AppTheme.danger,
duration: const Duration(seconds: 5)));
}
}
}
}
Future<void> _confirmReject(
BuildContext context, StockOutOrder 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(stockOutListProvider.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));
}
}
}
}
}
class _DropdownFilter extends StatelessWidget {
class _StatusFilterDropdown extends StatelessWidget {
final String value;
final List<String> items;
final ValueChanged<String?> onChanged;
final String hint;
const _DropdownFilter({
const _StatusFilterDropdown({
required this.value,
required this.items,
required this.onChanged,
required this.hint,
});
@override
@@ -355,16 +397,16 @@ class _DropdownFilter extends StatelessWidget {
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
hint: Text(hint, style: const TextStyle(fontSize: 13)),
items: 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))),
DropdownMenuItem(value: 'draft', child: Text('草稿', style: TextStyle(fontSize: 13))),
DropdownMenuItem(value: 'pending', child: Text('待审核', style: TextStyle(fontSize: 13))),
DropdownMenuItem(value: 'approved', child: Text('已审核', style: TextStyle(fontSize: 13))),
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),
),
),
);