Files
jiu/client/lib/screens/settings/settings_screen.dart
T
wangjia d717fb3735 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>
2026-04-07 22:20:28 +08:00

656 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../models/warehouse.dart';
import '../../providers/warehouse_provider.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 4,
child: Column(
children: [
Container(
color: AppTheme.surface,
child: const TabBar(
isScrollable: true,
labelColor: AppTheme.primary,
unselectedLabelColor: AppTheme.textSecondary,
indicatorColor: AppTheme.primary,
indicatorWeight: 2,
labelStyle:
TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
tabs: [
Tab(text: '用户管理'),
Tab(text: '仓库管理'),
Tab(text: '编号规则'),
Tab(text: '系统参数'),
],
),
),
const Divider(height: 1),
Expanded(
child: TabBarView(
children: [
_buildUsersTab(),
_buildWarehousesTab(),
_buildNumberRulesTab(),
_buildSystemParamsTab(),
],
),
),
],
),
);
}
Widget _buildUsersTab() {
final users = [
{'name': '张三', 'username': 'zhangsan', 'role': '管理员', 'status': true, 'lastLogin': '2026-04-04 09:15'},
{'name': '李四', 'username': 'lisi', 'role': '操作员', 'status': true, 'lastLogin': '2026-04-04 08:30'},
{'name': '王五', 'username': 'wangwu', 'role': '操作员', 'status': true, 'lastLogin': '2026-04-03 17:45'},
{'name': '赵六', 'username': 'zhaoliu', 'role': '只读', 'status': false, 'lastLogin': '2026-03-28 10:20'},
];
return Column(
children: [
Container(
height: 52,
color: AppTheme.surface,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
ElevatedButton.icon(
onPressed: () => _showAddUserDialog(context),
icon: const Icon(Icons.person_add, size: 16),
label: const Text('新增用户'),
),
],
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
child: DataTable(
headingRowColor: WidgetStateProperty.all(const Color(0xFFF0F4FF)),
columns: const [
DataColumn(label: Text('姓名')),
DataColumn(label: Text('用户名')),
DataColumn(label: Text('角色')),
DataColumn(label: Text('状态')),
DataColumn(label: Text('最后登录')),
DataColumn(label: Text('操作')),
],
rows: users
.map((u) => DataRow(cells: [
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 14,
backgroundColor: AppTheme.primary.withOpacity(0.15),
child: Text(
(u['name'] as String).substring(0, 1),
style: const TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
Text(u['name'] as String),
],
)),
DataCell(Text(u['username'] as String,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12))),
DataCell(_RoleBadge(u['role'] as String)),
DataCell(Switch(
value: u['status'] as bool,
onChanged: (_) {},
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
)),
DataCell(Text(u['lastLogin'] as String,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary))),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {},
child: const Text('编辑',
style: TextStyle(fontSize: 12))),
TextButton(
onPressed: () {},
child: const Text('重置密码',
style: TextStyle(fontSize: 12))),
],
)),
]))
.toList(),
),
),
),
],
);
}
Widget _buildWarehousesTab() {
final asyncWarehouses = ref.watch(warehouseListProvider);
return Column(
children: [
Container(
height: 52,
color: AppTheme.surface,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
ElevatedButton.icon(
onPressed: () => _showWarehouseDialog(context),
icon: const Icon(Icons.add, size: 16),
label: const Text('新建'),
),
],
),
),
const Divider(height: 1),
Expanded(
child: asyncWarehouses.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(warehouseListProvider.notifier).reload(),
child: const Text('重试'),
),
],
),
),
data: (warehouses) {
if (warehouses.isEmpty) {
return const Center(
child: Text('暂无仓库',
style:
TextStyle(color: AppTheme.textSecondary)));
}
return SingleChildScrollView(
child: DataTable(
headingRowColor:
WidgetStateProperty.all(const Color(0xFFF0F4FF)),
columns: const [
DataColumn(label: Text('仓库名称')),
DataColumn(label: Text('位置')),
DataColumn(label: Text('默认仓库')),
DataColumn(label: Text('操作')),
],
rows: warehouses
.map((w) => DataRow(cells: [
DataCell(Text(w.name,
style: const TextStyle(
fontWeight: FontWeight.w500))),
DataCell(Text(w.location ?? '-')),
DataCell(w.isDefault
? const Icon(Icons.check_circle,
color: AppTheme.success, size: 18)
: const SizedBox()),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
key: Key('btn_edit_${w.id}'),
onPressed: () =>
_showWarehouseDialog(context,
warehouse: w),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
TextButton(
key: Key('btn_delete_${w.id}'),
onPressed: () =>
_confirmDeleteWarehouse(context, w),
child: const Text('删除',
style: TextStyle(
fontSize: 12,
color: AppTheme.danger)),
),
],
)),
]))
.toList(),
),
);
},
),
),
],
);
}
Future<void> _confirmDeleteWarehouse(
BuildContext context, Warehouse w) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: Text('确认删除仓库「${w.name}」?'),
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(warehouseListProvider.notifier)
.deleteWarehouse(w.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));
}
}
}
}
void _showWarehouseDialog(BuildContext context, {Warehouse? warehouse}) {
showDialog(
context: context,
builder: (ctx) => _WarehouseFormDialog(
warehouse: warehouse,
onSaved: () =>
ref.read(warehouseListProvider.notifier).reload(),
),
);
}
Widget _buildNumberRulesTab() {
final rules = [
{'type': '入库单', 'prefix': 'RK', 'format': 'RK{年}{月}{日}{序号4}', 'example': 'RK20260404001', 'currentNo': 4},
{'type': '出库单', 'prefix': 'CK', 'format': 'CK{年}{月}{日}{序号4}', 'example': 'CK20260404001', 'currentNo': 2},
{'type': '盘点单', 'prefix': 'PD', 'format': 'PD{年}{月}{日}{序号4}', 'example': 'PD20260404001', 'currentNo': 1},
{'type': '商品编码', 'prefix': 'SP', 'format': 'SP{序号3}', 'example': 'SP001', 'currentNo': 12},
];
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('编号规则配置',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
const Text('配置各类单据的自动编号规则,修改后对新建单据生效',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
const SizedBox(height: 16),
Card(
child: DataTable(
headingRowColor:
WidgetStateProperty.all(const Color(0xFFF0F4FF)),
columns: const [
DataColumn(label: Text('单据类型')),
DataColumn(label: Text('前缀')),
DataColumn(label: Text('格式')),
DataColumn(label: Text('示例')),
DataColumn(label: Text('当前序号'), numeric: true),
DataColumn(label: Text('操作')),
],
rows: rules
.map((r) => DataRow(cells: [
DataCell(Text(r['type'] as String,
style: const TextStyle(fontWeight: FontWeight.w500))),
DataCell(Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(3),
),
child: Text(r['prefix'] as String,
style: const TextStyle(
color: AppTheme.primary,
fontFamily: 'monospace',
fontSize: 13,
fontWeight: FontWeight.w600)),
)),
DataCell(Text(r['format'] as String,
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary))),
DataCell(Text(r['example'] as String,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12))),
DataCell(Text('${r['currentNo']}')),
DataCell(TextButton(
onPressed: () {},
child: const Text('编辑', style: TextStyle(fontSize: 12)))),
]))
.toList(),
),
),
],
),
);
}
Widget _buildSystemParamsTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('系统参数',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('基本设置',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)),
const Divider(height: 24),
_ParamRow(label: '系统名称', value: '酒库管理系统'),
_ParamRow(label: '货币单位', value: '人民币(CNY'),
_ParamRow(label: '日期格式', value: 'YYYY-MM-DD'),
_ParamRow(label: '时区', value: 'Asia/Shanghai (UTC+8)'),
const SizedBox(height: 16),
const Text('审核设置',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)),
const Divider(height: 24),
_ParamRow(label: '入库单需要审核', value: '', isSwitch: true),
_ParamRow(label: '出库单需要审核', value: '', isSwitch: true),
_ParamRow(label: '允许超量出库', value: '', isSwitch: false),
],
),
),
),
const SizedBox(height: 12),
Row(
children: [
ElevatedButton(
onPressed: () {},
child: const Text('保存设置'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () {},
child: const Text('重置默认'),
),
],
),
],
),
);
}
void _showAddUserDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('新增用户'),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const TextField(
decoration: InputDecoration(labelText: '姓名', hintText: '请输入姓名'),
),
const SizedBox(height: 12),
const TextField(
decoration: InputDecoration(labelText: '用户名', hintText: '请输入登录用户名'),
),
const SizedBox(height: 12),
const TextField(
obscureText: true,
decoration: InputDecoration(labelText: '初始密码', hintText: '请设置初始密码'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: '操作员',
items: ['管理员', '操作员', '只读']
.map((r) => DropdownMenuItem(value: r, child: Text(r)))
.toList(),
onChanged: (_) {},
decoration: const InputDecoration(labelText: '角色'),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('确定'),
),
],
),
);
}
}
class _WarehouseFormDialog extends ConsumerStatefulWidget {
final Warehouse? warehouse;
final VoidCallback onSaved;
const _WarehouseFormDialog({this.warehouse, required this.onSaved});
@override
ConsumerState<_WarehouseFormDialog> createState() =>
_WarehouseFormDialogState();
}
class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _locationCtrl;
late bool _isDefault;
bool _saving = false;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.warehouse?.name ?? '');
_locationCtrl =
TextEditingController(text: widget.warehouse?.location ?? '');
_isDefault = widget.warehouse?.isDefault ?? false;
}
@override
void dispose() {
_nameCtrl.dispose();
_locationCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
final data = {
'name': _nameCtrl.text.trim(),
if (_locationCtrl.text.trim().isNotEmpty)
'location': _locationCtrl.text.trim(),
'is_default': _isDefault,
};
try {
final notifier = ref.read(warehouseListProvider.notifier);
if (widget.warehouse != null) {
await notifier.updateWarehouse(widget.warehouse!.id, data);
} else {
await notifier.createWarehouse(data);
}
if (mounted) {
Navigator.of(context).pop();
widget.onSaved();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.warehouse != null ? '仓库更新成功' : '仓库创建成功'),
backgroundColor: AppTheme.success,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败:$e'),
backgroundColor: AppTheme.danger),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.warehouse != null ? '编辑仓库' : '新建仓库'),
content: SizedBox(
width: 400,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: '仓库名称'),
validator: (v) =>
(v == null || v.isEmpty) ? '不能为空' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _locationCtrl,
decoration: const InputDecoration(labelText: '位置'),
),
const SizedBox(height: 12),
CheckboxListTile(
title: const Text('设为默认仓库'),
value: _isDefault,
onChanged: (v) => setState(() => _isDefault = v ?? false),
contentPadding: EdgeInsets.zero,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
ElevatedButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('保存'),
),
],
);
}
}
class _RoleBadge extends StatelessWidget {
final String role;
const _RoleBadge(this.role);
@override
Widget build(BuildContext context) {
final Color bg;
final Color fg;
switch (role) {
case '管理员':
bg = const Color(0xFFE3F2FD);
fg = AppTheme.primary;
break;
case '操作员':
bg = const Color(0xFFE8F5E9);
fg = AppTheme.success;
break;
default:
bg = const Color(0xFFF5F5F5);
fg = AppTheme.textSecondary;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)),
child: Text(role, style: TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
);
}
}
class _ParamRow extends StatelessWidget {
final String label;
final String value;
final bool? isSwitch;
const _ParamRow({required this.label, required this.value, this.isSwitch});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
SizedBox(
width: 180,
child: Text(label,
style: const TextStyle(fontSize: 14, color: AppTheme.textSecondary)),
),
if (isSwitch != null)
Switch(
value: isSwitch!,
onChanged: (_) {},
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)
else
Text(value,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
const Spacer(),
TextButton(onPressed: () {}, child: const Text('修改', style: TextStyle(fontSize: 12))),
],
),
);
}
}