Files
jiu/client/lib/screens/settings/settings_screen.dart
T
wangjia 97ca4c7f72 fix(client): 修复导入静默失败问题
捕获所有异常(非仅 DioException),兼容 ImportProducts 响应格式(无 total/skipped),
确保任何错误都在 UI 上显示而不是静默消失。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 19:39:39 +08:00

1693 lines
59 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:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/auth/auth_state.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../models/number_rule.dart';
import '../../models/user.dart';
import '../../models/warehouse.dart';
import '../../providers/license_provider.dart';
import '../../providers/number_rule_provider.dart';
import '../../providers/update_provider.dart';
import '../../providers/user_provider.dart';
import '../../providers/warehouse_provider.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
// System params local state (UI only, no backend yet)
String _sysName = '酒库管理系统';
String _sysCurrency = '人民币(CNY';
String _sysDateFormat = 'YYYY-MM-DD';
String _sysTimezone = 'Asia/Shanghai (UTC+8)';
bool _requireStockInApproval = true;
bool _requireStockOutApproval = true;
bool _allowOverstock = false;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 6,
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: '系统参数'),
Tab(text: '数据导入'),
Tab(text: '关于'),
],
),
),
const Divider(height: 1),
Expanded(
child: TabBarView(
children: [
_buildUsersTab(),
_buildWarehousesTab(),
_buildNumberRulesTab(),
_buildSystemParamsTab(),
_buildImportTab(),
_buildAboutTab(),
],
),
),
],
),
);
}
Widget _buildUsersTab() {
final asyncUsers = ref.watch(userListProvider);
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: asyncUsers.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用',
style: const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.read(userListProvider.notifier).reload(),
child: const Text('重试'),
),
],
),
),
data: (users) => 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('操作')),
],
rows: users
.map((u) => DataRow(cells: [
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 14,
backgroundColor:
AppTheme.primary.withOpacity(0.15),
child: Text(
(u.realName ?? u.username)
.substring(0, 1),
style: const TextStyle(
fontSize: 12,
color: AppTheme.primary,
fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 8),
Text(u.realName ?? u.username),
],
)),
DataCell(Text(u.username,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12))),
DataCell(_RoleBadge(u.roleLabel)),
DataCell(Switch(
value: u.isActive,
onChanged: (v) => ref
.read(userListProvider.notifier)
.updateUser(u.id, {'is_active': v}),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () =>
_showEditUserDialog(context, u),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
TextButton(
onPressed: () =>
_showResetPasswordDialog(context, u),
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: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用',
style: const TextStyle(color: AppTheme.textSecondary)),
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 asyncRules = ref.watch(numberRuleListProvider);
return asyncRules.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用',
style: const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>
ref.read(numberRuleListProvider.notifier).reload(),
child: const Text('重试'),
),
],
),
),
data: (rules) => 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.typeLabel,
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,
style: const TextStyle(
color: AppTheme.primary,
fontFamily: 'monospace',
fontSize: 13,
fontWeight: FontWeight.w600)),
)),
DataCell(Text(r.dateFormat,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary))),
DataCell(Text(r.exampleNo,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12))),
DataCell(Text('${r.currentNo}')),
DataCell(TextButton(
onPressed: () =>
_showNumberRuleDialog(context, r),
child: const Text('编辑',
style: TextStyle(fontSize: 12)))),
]))
.toList(),
),
),
],
),
),
);
}
void _showNumberRuleDialog(BuildContext context, NumberRule rule) {
final prefixCtrl = TextEditingController(text: rule.prefix);
final currentNoCtrl =
TextEditingController(text: '${rule.currentNo}');
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('编辑编号规则 — ${rule.typeLabel}'),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: prefixCtrl,
decoration: const InputDecoration(labelText: '前缀'),
),
const SizedBox(height: 12),
TextField(
controller: currentNoCtrl,
decoration: const InputDecoration(labelText: '当前序号'),
keyboardType: TextInputType.number,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消')),
ElevatedButton(
onPressed: () async {
Navigator.of(ctx).pop();
try {
await ref
.read(numberRuleListProvider.notifier)
.updateRule(rule.id, {
'prefix': prefixCtrl.text.trim(),
'current_no': int.tryParse(currentNoCtrl.text) ?? rule.currentNo,
});
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('编号规则已更新'),
backgroundColor: AppTheme.success),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('更新失败:$e'),
backgroundColor: AppTheme.danger),
);
}
}
},
child: const Text('保存'),
),
],
),
);
}
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: _sysName,
onEdit: () => _showEditParamDialog('系统名称', _sysName,
(v) => setState(() => _sysName = v)),
),
_ParamRow(
label: '货币单位',
value: _sysCurrency,
onEdit: () => _showEditParamDialog('货币单位', _sysCurrency,
(v) => setState(() => _sysCurrency = v)),
),
_ParamRow(
label: '日期格式',
value: _sysDateFormat,
onEdit: () => _showEditParamDialog('日期格式', _sysDateFormat,
(v) => setState(() => _sysDateFormat = v)),
),
_ParamRow(
label: '时区',
value: _sysTimezone,
onEdit: () => _showEditParamDialog('时区', _sysTimezone,
(v) => setState(() => _sysTimezone = v)),
),
const SizedBox(height: 16),
const Text('审核设置',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)),
const Divider(height: 24),
_ParamRow(
label: '入库单需要审核',
value: _requireStockInApproval ? '' : '',
switchValue: _requireStockInApproval,
onSwitchChanged: (v) =>
setState(() => _requireStockInApproval = v),
),
_ParamRow(
label: '出库单需要审核',
value: _requireStockOutApproval ? '' : '',
switchValue: _requireStockOutApproval,
onSwitchChanged: (v) =>
setState(() => _requireStockOutApproval = v),
),
_ParamRow(
label: '允许超量出库',
value: _allowOverstock ? '' : '',
switchValue: _allowOverstock,
onSwitchChanged: (v) =>
setState(() => _allowOverstock = v),
),
],
),
),
),
const SizedBox(height: 12),
Row(
children: [
ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('设置已保存'),
backgroundColor: AppTheme.success));
},
child: const Text('保存设置'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => setState(() {
_sysName = '酒库管理系统';
_sysCurrency = '人民币(CNY';
_sysDateFormat = 'YYYY-MM-DD';
_sysTimezone = 'Asia/Shanghai (UTC+8)';
_requireStockInApproval = true;
_requireStockOutApproval = true;
_allowOverstock = false;
}),
child: const Text('重置默认'),
),
],
),
],
),
);
}
// ── 数据导入 Tab ──────────────────────────────────────────
Widget _buildImportTab() => const _BatchImportWidget();
// ── 关于 Tab ─────────────────────────────────────────────
Widget _buildAboutTab() {
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
final updateInfo = ref.watch(updateProvider).valueOrNull;
final licenseAsync = ref.watch(licenseProvider);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── 版本信息 ──
_AboutSection(
title: '版本信息',
children: [
_AboutRow(label: '当前版本', value: appVersion),
if (updateInfo != null && updateInfo.hasUpdate)
_AboutRow(
label: '最新版本',
value: 'v${updateInfo.latestVersion}',
valueColor: AppTheme.success,
trailing: TextButton(
onPressed: () => launchUpdateUrl(updateInfo.downloadUrls),
child: const Text('立即更新'),
),
)
else
_AboutRow(
label: '最新版本',
value: updateInfo != null ? '已是最新' : '检查中…',
valueColor: AppTheme.textSecondary,
trailing: TextButton(
onPressed: () =>
ref.read(updateProvider.notifier).forceCheck(),
child: const Text('检查更新'),
),
),
],
),
const SizedBox(height: 20),
// ── 授权信息 ──
_AboutSection(
title: '授权信息',
children: [
licenseAsync.when(
loading: () => const _AboutRow(label: '授权状态', value: '加载中…'),
error: (_, __) =>
const _AboutRow(label: '授权状态', value: '暂无授权信息'),
data: (lic) {
if (lic == null) {
return const _AboutRow(label: '授权状态', value: '未激活');
}
return Column(
children: [
_AboutRow(label: '授权类型', value: lic.typeLabel),
_AboutRow(
label: '授权状态',
value: lic.isExpired
? '已过期'
: lic.isActive
? '正常'
: '已停用',
valueColor: lic.isExpired
? AppTheme.danger
: lic.isActive
? AppTheme.success
: AppTheme.textSecondary,
),
if (lic.expiresAt != null)
_AboutRow(
label: '到期时间',
value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!),
trailing: lic.daysRemaining != null &&
lic.daysRemaining! <= 30
? Chip(
label: Text(
lic.isExpired
? '已过期'
: '剩余 ${lic.daysRemaining}',
style: const TextStyle(
fontSize: 11, color: Colors.white),
),
backgroundColor: lic.isExpired
? AppTheme.danger
: Colors.orange,
padding: EdgeInsets.zero,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
)
: null,
)
else
const _AboutRow(label: '到期时间', value: '永久有效'),
if (lic.activatedAt != null)
_AboutRow(
label: '激活时间',
value: DateFormat('yyyy-MM-dd')
.format(lic.activatedAt!),
),
],
);
},
),
const SizedBox(height: 8),
Row(
children: [
OutlinedButton.icon(
onPressed: () => _showRenewDialog(),
icon: const Icon(Icons.card_membership, size: 16),
label: const Text('续费 / 升级授权'),
),
],
),
],
),
const SizedBox(height: 20),
// ── 关于我们 ──
_AboutSection(
title: '关于我们',
children: [
const _AboutRow(label: '开发商', value: '酒库科技有限公司'),
const _AboutRow(label: '官方网站', value: 'https://jiu.example.com'),
const _AboutRow(label: '联系邮箱', value: 'support@jiu.example.com'),
const _AboutRow(label: '技术支持', value: '周一至周五 9:00 - 18:00'),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: () async {
final uri = Uri.parse('mailto:support@jiu.example.com'
'?subject=酒库管理系统咨询');
if (await canLaunchUrl(uri)) launchUrl(uri);
},
icon: const Icon(Icons.email_outlined, size: 16),
label: const Text('发送邮件'),
),
],
),
],
),
const SizedBox(height: 20),
// ── 意见反馈 ──
_AboutSection(
title: '意见反馈',
children: [
const _AboutRow(
label: '问题反馈',
value: '遇到 Bug 或有功能建议,欢迎告知我们',
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ElevatedButton.icon(
onPressed: () => _showFeedbackDialog(isBug: true),
icon: const Icon(Icons.bug_report_outlined, size: 16),
label: const Text('反馈 Bug'),
),
OutlinedButton.icon(
onPressed: () => _showFeedbackDialog(isBug: false),
icon: const Icon(Icons.lightbulb_outline, size: 16),
label: const Text('功能建议'),
),
],
),
],
),
],
),
);
}
void _showRenewDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('续费 / 升级授权'),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('请联系我们获取续费报价:'),
SizedBox(height: 12),
SelectableText('📧 support@jiu.example.com',
style: TextStyle(fontSize: 13)),
SizedBox(height: 6),
SelectableText('📞 400-000-0000',
style: TextStyle(fontSize: 13)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('关闭'),
),
ElevatedButton(
onPressed: () async {
await Clipboard.setData(
const ClipboardData(text: 'support@jiu.example.com'));
if (ctx.mounted) {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('邮箱已复制到剪贴板')),
);
}
},
child: const Text('复制邮箱'),
),
],
),
);
}
void _showFeedbackDialog({required bool isBug}) {
final ctrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(isBug ? '反馈 Bug' : '功能建议'),
content: SizedBox(
width: 400,
child: TextField(
controller: ctrl,
maxLines: 6,
decoration: InputDecoration(
hintText: isBug
? '请描述问题的复现步骤和预期行为…'
: '请描述您希望增加的功能…',
border: const OutlineInputBorder(),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () async {
final subject = Uri.encodeComponent(isBug ? 'Bug反馈' : '功能建议');
final body = Uri.encodeComponent(ctrl.text);
final uri = Uri.parse(
'mailto:support@jiu.example.com?subject=$subject&body=$body');
if (await canLaunchUrl(uri)) launchUrl(uri);
if (ctx.mounted) Navigator.pop(ctx);
},
child: const Text('通过邮件发送'),
),
],
),
);
}
void _showEditParamDialog(
String label, String current, ValueChanged<String> onSave) {
final ctrl = TextEditingController(text: current);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('修改$label'),
content: SizedBox(
width: 320,
child: TextField(
controller: ctrl,
autofocus: true,
decoration: InputDecoration(labelText: label),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消')),
ElevatedButton(
onPressed: () {
final v = ctrl.text.trim();
if (v.isNotEmpty) onSave(v);
Navigator.of(ctx).pop();
},
child: const Text('确定'),
),
],
),
);
}
void _showAddUserDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => _UserFormDialog(
onSaved: () => ref.read(userListProvider.notifier).reload(),
),
);
}
void _showEditUserDialog(BuildContext context, AppUser user) {
showDialog(
context: context,
builder: (ctx) => _UserFormDialog(
user: user,
onSaved: () => ref.read(userListProvider.notifier).reload(),
),
);
}
void _showResetPasswordDialog(BuildContext context, AppUser user) {
showDialog(
context: context,
builder: (ctx) => _ResetPasswordDialog(user: user),
);
}
}
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 _UserFormDialog extends ConsumerStatefulWidget {
final AppUser? user;
final VoidCallback onSaved;
const _UserFormDialog({this.user, required this.onSaved});
@override
ConsumerState<_UserFormDialog> createState() => _UserFormDialogState();
}
class _UserFormDialogState extends ConsumerState<_UserFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _usernameCtrl;
late final TextEditingController _realNameCtrl;
late final TextEditingController _phoneCtrl;
late final TextEditingController _passwordCtrl;
late String _role;
bool _saving = false;
bool get _isEdit => widget.user != null;
@override
void initState() {
super.initState();
_usernameCtrl = TextEditingController(text: widget.user?.username ?? '');
_realNameCtrl = TextEditingController(text: widget.user?.realName ?? '');
_phoneCtrl = TextEditingController(text: widget.user?.phone ?? '');
_passwordCtrl = TextEditingController();
_role = widget.user?.role ?? 'operator';
}
@override
void dispose() {
_usernameCtrl.dispose();
_realNameCtrl.dispose();
_phoneCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
final notifier = ref.read(userListProvider.notifier);
if (_isEdit) {
await notifier.updateUser(widget.user!.id, {
'real_name': _realNameCtrl.text.trim(),
'phone': _phoneCtrl.text.trim(),
'role': _role,
});
} else {
await notifier.createUser({
'username': _usernameCtrl.text.trim(),
'password': _passwordCtrl.text,
'real_name': _realNameCtrl.text.trim(),
'phone': _phoneCtrl.text.trim(),
'role': _role,
});
}
if (mounted) {
Navigator.of(context).pop();
widget.onSaved();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(_isEdit ? '用户更新成功' : '用户创建成功'),
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(_isEdit ? '编辑用户' : '新增用户'),
content: SizedBox(
width: 400,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!_isEdit)
TextFormField(
controller: _usernameCtrl,
decoration: const InputDecoration(labelText: '用户名'),
validator: (v) => (v == null || v.isEmpty) ? '不能为空' : null,
),
if (!_isEdit) const SizedBox(height: 12),
TextFormField(
controller: _realNameCtrl,
decoration: const InputDecoration(labelText: '姓名'),
),
const SizedBox(height: 12),
TextFormField(
controller: _phoneCtrl,
decoration: const InputDecoration(labelText: '手机号'),
),
const SizedBox(height: 12),
if (!_isEdit) ...[
TextFormField(
controller: _passwordCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: '初始密码'),
validator: (v) => (v == null || v.isEmpty) ? '不能为空' : null,
),
const SizedBox(height: 12),
],
DropdownButtonFormField<String>(
value: _role,
items: const [
DropdownMenuItem(value: 'admin', child: Text('管理员')),
DropdownMenuItem(value: 'operator', child: Text('操作员')),
DropdownMenuItem(value: 'readonly', child: Text('只读')),
],
onChanged: (v) => setState(() => _role = v ?? 'operator'),
decoration: const InputDecoration(labelText: '角色'),
),
],
),
),
),
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 _ResetPasswordDialog extends ConsumerStatefulWidget {
final AppUser user;
const _ResetPasswordDialog({required this.user});
@override
ConsumerState<_ResetPasswordDialog> createState() => _ResetPasswordDialogState();
}
class _ResetPasswordDialogState extends ConsumerState<_ResetPasswordDialog> {
final _ctrl = TextEditingController();
bool _saving = false;
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (_ctrl.text.isEmpty) return;
setState(() => _saving = true);
try {
await ref.read(userListProvider.notifier).resetPassword(widget.user.id, _ctrl.text);
if (mounted) {
Navigator.of(context).pop();
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,
));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('重置密码 — ${widget.user.username}'),
content: SizedBox(
width: 360,
child: TextField(
controller: _ctrl,
obscureText: true,
decoration: const InputDecoration(labelText: '新密码'),
),
),
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? switchValue;
final ValueChanged<bool>? onSwitchChanged;
final VoidCallback? onEdit;
const _ParamRow({
required this.label,
required this.value,
this.switchValue,
this.onSwitchChanged,
this.onEdit,
});
@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 (switchValue != null)
Switch(
value: switchValue!,
onChanged: onSwitchChanged,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)
else
Text(value,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w500)),
const Spacer(),
if (onEdit != null)
TextButton(
onPressed: onEdit,
child: const Text('修改', style: TextStyle(fontSize: 12)),
),
],
),
);
}
}
// ── 关于页辅助 widgets ──────────────────────────────────────
class _AboutSection extends StatelessWidget {
final String title;
final List<Widget> children;
const _AboutSection({required this.title, required this.children});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.primaryDark)),
const Divider(height: 24),
...children,
],
),
),
);
}
}
class _AboutRow extends StatelessWidget {
final String label;
final String value;
final Color? valueColor;
final Widget? trailing;
const _AboutRow({
required this.label,
required this.value,
this.valueColor,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 88,
child: Text(label,
style: const TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
),
Expanded(
child: Text(value,
style: TextStyle(
fontSize: 13,
color: valueColor ?? AppTheme.textPrimary,
fontWeight: FontWeight.w500)),
),
if (trailing != null) trailing!,
],
),
);
}
}
// ── 批量数据导入 ───────────────────────────────────────────
class _ImportSlot {
final String title;
final String endpoint;
final String hint;
PlatformFile? file;
// null = 未运行;true = 成功;false = 失败
bool? success;
String? error;
int total = 0;
int imported = 0;
int skipped = 0;
_ImportSlot(this.title, this.endpoint, this.hint);
bool get hasResult => success != null;
}
class _BatchImportWidget extends ConsumerStatefulWidget {
const _BatchImportWidget();
@override
ConsumerState<_BatchImportWidget> createState() => _BatchImportWidgetState();
}
class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
bool _loading = false;
String? _lastDir;
static const _prefKey = 'import_last_dir';
late final List<_ImportSlot> _slots = [
_ImportSlot('往来单位', '/import/partners',
'格式:编号 | 类型 | 状态 | 名称 | 电话 | 卡号 | 初始金额 | 单位 | 地址 | 备注'),
_ImportSlot('商品名称', '/import/product-names',
'格式:选项编号 | 选项名称 | 备注'),
_ImportSlot('商品系列', '/import/product-series',
'格式:选项编号 | 选项名称 | 备注'),
_ImportSlot('商品规格', '/import/product-specs',
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
];
@override
void initState() {
super.initState();
SharedPreferences.getInstance().then((prefs) {
final dir = prefs.getString(_prefKey);
if (dir != null && mounted) setState(() => _lastDir = dir);
});
}
Future<void> _pickFile(int index) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['xls', 'xlsx'],
withData: true,
initialDirectory: _lastDir,
);
if (result == null) return;
// 保存目录供下次使用
final path = result.files.first.path;
if (path != null) {
final dir = path.contains('/') ? path.substring(0, path.lastIndexOf('/')) : null;
if (dir != null) {
_lastDir = dir;
SharedPreferences.getInstance().then((p) => p.setString(_prefKey, dir));
}
}
setState(() {
_slots[index].file = result.files.first;
_slots[index].success = null;
_slots[index].error = null;
});
}
Future<void> _runImport() async {
final token = ref.read(authStateProvider).user?.accessToken ?? '';
if (!_slots.any((s) => s.file != null)) return;
setState(() {
_loading = true;
for (final s in _slots) {
if (s.file != null) {
s.success = null;
s.error = null;
}
}
});
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
dio.options.headers['Authorization'] = 'Bearer $token';
for (final slot in _slots) {
if (slot.file == null) continue;
final bytes = slot.file!.bytes;
if (bytes == null) {
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
continue;
}
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
});
final resp = await dio.post(slot.endpoint, data: formData);
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
if (mounted) setState(() {
slot.success = true;
slot.total = (data['total'] ?? data['imported'] ?? 0) as int;
slot.imported = (data['imported'] ?? 0) as int;
slot.skipped = (data['skipped'] ?? 0) as int;
});
} on DioException catch (e) {
final raw = e.response?.data;
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
if (mounted) setState(() {
slot.success = false;
slot.error = msg.toString();
});
} catch (e) {
if (mounted) setState(() {
slot.success = false;
slot.error = e.toString();
});
}
}
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
final hasAnyFile = _slots.any((s) => s.file != null);
final ran = _slots.where((s) => s.file != null && s.hasResult).toList();
final allDone = !_loading && ran.length == _slots.where((s) => s.file != null).length && ran.isNotEmpty;
final failCount = ran.where((s) => s.success == false).length;
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(
'选择对应的 Excel 文件后点击「全部导入」,系统将依次导入,按名称去重(已存在的数据不重复导入)。',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
const SizedBox(height: 20),
Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Column(
children: [
for (int i = 0; i < _slots.length; i++) ...[
_buildSlotRow(i),
if (i < _slots.length - 1)
const Divider(height: 1),
],
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton.icon(
onPressed: (_loading || !hasAnyFile) ? null : _runImport,
icon: _loading
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Icon(Icons.upload_rounded, size: 16),
label: Text(_loading ? '导入中...' : '全部导入'),
),
const SizedBox(width: 16),
if (allDone) ...[
Icon(
failCount == 0
? Icons.check_circle_outline
: Icons.warning_amber_rounded,
size: 16,
color: failCount == 0 ? AppTheme.success : Colors.orange,
),
const SizedBox(width: 4),
Text(
failCount == 0 ? '全部导入完成' : '$failCount 项失败,请检查错误信息',
style: TextStyle(
fontSize: 13,
color: failCount == 0 ? AppTheme.success : Colors.orange,
),
),
],
],
),
],
),
);
}
Widget _buildSlotRow(int index) {
final slot = _slots[index];
Widget? statusWidget;
if (slot.hasResult) {
if (slot.success == true) {
final parts = <String>[
'${slot.total}',
'新增 ${slot.imported}',
if (slot.skipped > 0) '重复跳过 ${slot.skipped}',
];
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle_outline, size: 15, color: AppTheme.success),
const SizedBox(width: 4),
Text(
parts.join(''),
style: const TextStyle(fontSize: 13, color: AppTheme.success),
),
],
);
} else {
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 15, color: AppTheme.danger),
const SizedBox(width: 4),
Text(
slot.error ?? '未知错误',
style: const TextStyle(fontSize: 13, color: AppTheme.danger),
),
],
);
}
} else if (_loading && slot.file != null) {
statusWidget = const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
SizedBox(
width: 68,
child: Text(slot.title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: _loading ? null : () => _pickFile(index),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('选择文件', style: TextStyle(fontSize: 13)),
),
const SizedBox(width: 12),
Expanded(
child: Text(
slot.file?.name ?? '未选择',
style: TextStyle(
fontSize: 13,
color: slot.file != null ? AppTheme.textPrimary : AppTheme.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
if (statusWidget != null) statusWidget,
],
),
);
}
}