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 createState() => _SettingsScreenState(); } class _SettingsScreenState extends ConsumerState { // 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 _confirmDeleteWarehouse( BuildContext context, Warehouse w) async { final confirmed = await showDialog( 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 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(); 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 _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(); 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 _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( 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 _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? 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 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 _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 _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 : {}; 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 = [ '共 ${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, ], ), ); } }