Files
jiu/client/lib/screens/settings/settings_screen.dart
T
wangjia d5dc499c6b
Deploy / deploy (push) Successful in 1m15s
fix(import): 修复导入 504 超时 + 添加上传进度显示
nginx:
- 新增 /api/v1/import/ 专属 location,proxy_read_timeout 延长至 300s

后端:
- ImportInventory 预加载商品/仓库/库存(3 次 bulk query 替代 N+1)
- 批量写 inventory_log(CreateInBatches 替代逐行 Create)

前端:
- settings 导入行:显示"上传 XX%"进度条和"导入数据(Ns)"计时
- Dio 超时设置为 send=120s / receive=300s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:12:59 +08:00

2323 lines
81 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 'dart:async';
import '../../core/utils/dialog_util.dart';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.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';
import '../../providers/shop_provider.dart';
import '../../models/shop.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: 7,
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: '数据导入'),
Tab(text: '关于'),
],
),
),
const Divider(height: 1),
Expanded(
child: TabBarView(
children: [
_buildShopInfoTab(),
_buildUsersTab(),
_buildWarehousesTab(),
_buildNumberRulesTab(),
_buildSystemParamsTab(),
_buildImportTab(),
_buildAboutTab(),
],
),
),
],
),
);
}
Widget _buildShopInfoTab() {
final asyncShop = ref.watch(shopInfoProvider);
final currentUser = ref.watch(authStateProvider).user;
final isAdmin =
currentUser?.role == 'admin' || currentUser?.role == 'superadmin';
return asyncShop.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: TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () => ref.invalidate(shopInfoProvider),
child: const Text('重试'),
),
],
),
),
data: (shop) => 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: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShopInfoRow(
label: '门店编号',
value: shop.code.isNotEmpty ? shop.code : ''),
const Divider(height: 16),
_ShopInfoRow(
label: '门店名称',
value: shop.name.isNotEmpty ? shop.name : ''),
const Divider(height: 16),
_ShopInfoRow(
label: '门店地址',
value: shop.address.isNotEmpty ? shop.address : ''),
const Divider(height: 16),
_ShopInfoRow(
label: '联系电话',
value: shop.phone.isNotEmpty ? shop.phone : ''),
const Divider(height: 16),
_ShopInfoRow(
label: '负责人',
value:
shop.managerName.isNotEmpty ? shop.managerName : ''),
],
),
),
),
if (isAdmin) ...[
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _showEditShopDialog(context, shop),
icon: const Icon(Icons.edit_outlined, size: 16),
label: const Text('编辑信息'),
),
],
],
),
),
);
}
void _showEditShopDialog(BuildContext context, ShopInfo shop) {
showAppDialog(
context: context,
builder: (ctx) => _ShopEditDialog(
shop: shop,
onSaved: () => ref.invalidate(shopInfoProvider),
),
);
}
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}) {
showAppDialog(
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}');
showAppDialog(
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() {
final currentUser = ref.watch(authStateProvider).user;
final isSuperAdmin = currentUser?.role == 'superadmin';
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
}
// ── 关于 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() {
showAppDialog(
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();
showAppDialog(
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);
showAppDialog(
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) {
showAppDialog(
context: context,
builder: (ctx) => _UserFormDialog(
onSaved: () => ref.read(userListProvider.notifier).reload(),
),
);
}
void _showEditUserDialog(BuildContext context, AppUser user) {
showAppDialog(
context: context,
builder: (ctx) => _UserFormDialog(
user: user,
onSaved: () => ref.read(userListProvider.notifier).reload(),
),
);
}
void _showResetPasswordDialog(BuildContext context, AppUser user) {
showAppDialog(
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: 'superadmin', child: Text('超级管理员')),
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(0xFFFCE4EC);
fg = AppTheme.danger;
break;
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;
// 进度
int uploadPercent = 0;
bool isProcessing = false;
int processingSeconds = 0;
_ImportSlot(this.title, this.endpoint, this.hint);
bool get hasResult => success != null;
void resetProgress() {
uploadPercent = 0;
isProcessing = false;
processingSeconds = 0;
success = null;
error = null;
}
}
class _BatchImportWidget extends ConsumerStatefulWidget {
final bool isSuperAdmin;
const _BatchImportWidget({required this.isSuperAdmin});
@override
ConsumerState<_BatchImportWidget> createState() => _BatchImportWidgetState();
}
class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
bool _loading = false;
String? _lastDir;
OverlayEntry? _importBarrier;
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',
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
_ImportSlot('商品编码', '/import/product-codes',
'格式:商品编码:xxxx'),
_ImportSlot('库存', '/import/inventory',
'格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'),
];
final Set<String> _selectedTables = {};
bool _clearing = false;
static const _clearOptions = [
(key: 'stock_in', label: '入库单'),
(key: 'stock_out', label: '出库单'),
(key: 'inventory', label: '库存管理'),
(key: 'products', label: '商品详情'),
(key: 'partners', label: '往来单位'),
];
@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 {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['xls', 'xlsx'],
withData: true,
initialDirectory: kIsWeb ? null : _lastDir,
);
if (result == null || result.files.isEmpty) return;
// 保存目录供下次使用(仅非 web 平台)
if (!kIsWeb) {
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));
}
}
}
if (!mounted) return;
setState(() {
_slots[index].file = result.files.first;
_slots[index].success = null;
_slots[index].error = null;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('选择文件失败:$e')),
);
}
}
void _showBarrier() {
_importBarrier = OverlayEntry(
builder: (_) => Stack(children: [
const ModalBarrier(dismissible: false, color: Colors.transparent),
Positioned(
bottom: 24, left: 0, right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
),
child: const Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)),
SizedBox(width: 10),
Text('导入中,请勿切换页面…',
style: TextStyle(color: Colors.white, fontSize: 13)),
]),
),
),
),
]),
);
Overlay.of(context, rootOverlay: true).insert(_importBarrier!);
}
void _removeBarrier() {
_importBarrier?.remove();
_importBarrier = null;
}
Future<void> _runImport() async {
final token = ref.read(authStateProvider).user?.accessToken ?? '';
if (!_slots.any((s) => s.file != null)) return;
_showBarrier();
setState(() {
_loading = true;
for (final s in _slots) {
if (s.file != null) s.resetProgress();
}
});
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
sendTimeout: const Duration(seconds: 120),
receiveTimeout: const Duration(seconds: 300),
));
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;
}
Timer? processingTimer;
bool uploadDone = false;
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
});
final resp = await dio.post(
slot.endpoint,
data: formData,
onSendProgress: (sent, total) {
if (total <= 0 || !mounted) return;
if (sent >= total && !uploadDone) {
uploadDone = true;
setState(() { slot.isProcessing = true; slot.processingSeconds = 0; });
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => slot.processingSeconds++);
});
} else if (!uploadDone) {
final pct = (sent / total * 100).round().clamp(0, 99);
if (mounted) setState(() => slot.uploadPercent = pct);
}
},
);
processingTimer?.cancel();
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;
slot.isProcessing = false;
});
} on DioException catch (e) {
processingTimer?.cancel();
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();
slot.isProcessing = false;
});
} catch (e) {
processingTimer?.cancel();
if (mounted) setState(() {
slot.success = false;
slot.error = e.toString();
slot.isProcessing = false;
});
}
}
if (mounted) {
_removeBarrier();
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,
),
),
],
],
),
if (widget.isSuperAdmin) ...[
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 16),
_buildClearDataSection(),
],
],
),
);
}
Widget _buildClearDataSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行
Row(
children: [
const Icon(Icons.delete_forever, color: AppTheme.danger, size: 20),
const SizedBox(width: 8),
const Text('危险操作 — 数据清空',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.danger)),
],
),
const SizedBox(height: 12),
// 红色警告框
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFFFF3F3),
border: Border.all(color: AppTheme.danger.withOpacity(0.5)),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
const Icon(Icons.warning_amber_rounded,
color: AppTheme.danger, size: 18),
const SizedBox(width: 6),
const Text('警告:数据一旦清空,无法恢复!',
style: TextStyle(
color: AppTheme.danger,
fontWeight: FontWeight.w600,
fontSize: 13)),
]),
const SizedBox(height: 6),
const Text(
'• 清空操作仅删除当前门店的数据,不影响其他门店\n'
'• 操作不可撤销,请务必提前备份数据\n'
'• 仅超级管理员可执行此操作',
style: TextStyle(fontSize: 12, color: Color(0xFFB71C1C), height: 1.6),
),
],
),
),
const SizedBox(height: 14),
// 勾选框
Wrap(
spacing: 8,
runSpacing: 4,
children: _clearOptions.map((opt) {
final selected = _selectedTables.contains(opt.key);
return FilterChip(
label: Text(opt.label),
selected: selected,
selectedColor: AppTheme.danger.withOpacity(0.15),
checkmarkColor: AppTheme.danger,
labelStyle: TextStyle(
color: selected ? AppTheme.danger : AppTheme.textPrimary,
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
),
side: BorderSide(
color: selected ? AppTheme.danger : Colors.grey.shade300,
),
onSelected: _clearing
? null
: (v) => setState(() {
if (v) {
_selectedTables.add(opt.key);
} else {
_selectedTables.remove(opt.key);
}
}),
);
}).toList(),
),
const SizedBox(height: 14),
// 清空按钮
ElevatedButton.icon(
onPressed: (_clearing || _selectedTables.isEmpty)
? null
: () => _confirmClear(),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.shade300,
),
icon: _clearing
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.delete_sweep_outlined, size: 18),
label: Text(_clearing ? '清空中...' : '清空选中数据'),
),
],
);
}
Future<void> _confirmClear() async {
final tables = List<String>.from(_selectedTables);
final labels = _clearOptions
.where((o) => tables.contains(o.key))
.map((o) => o.label)
.toList();
final ctrl = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setS) => AlertDialog(
title: Row(children: [
const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22),
const SizedBox(width: 8),
const Text('确认清空数据', style: TextStyle(color: Colors.red)),
]),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('即将清空以下数据表:',
style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
...labels.map((l) => Padding(
padding: const EdgeInsets.only(left: 12, bottom: 4),
child: Row(children: [
const Icon(Icons.remove_circle,
color: Colors.red, size: 14),
const SizedBox(width: 6),
Text(l,
style: const TextStyle(
color: Colors.red, fontWeight: FontWeight.w600)),
]),
)),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFFF3F3),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.red.shade200),
),
child: const Text(
'⚠️ 此操作不可撤销,数据清空后无法恢复!',
style: TextStyle(
color: Colors.red,
fontSize: 13,
fontWeight: FontWeight.w600),
),
),
const SizedBox(height: 16),
const Text('请输入 "确认清空" 以继续:',
style: TextStyle(fontSize: 13)),
const SizedBox(height: 8),
TextField(
controller: ctrl,
autofocus: true,
decoration: const InputDecoration(
hintText: '确认清空',
border: OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => setS(() {}),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
ElevatedButton(
onPressed: ctrl.text == '确认清空'
? () => Navigator.of(ctx).pop(true)
: null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('确认清空'),
),
],
),
),
);
ctrl.dispose();
if (confirmed != true || !mounted) return;
setState(() => _clearing = true);
try {
final token = ref.read(authStateProvider).user?.accessToken ?? '';
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
dio.options.headers['Authorization'] = 'Bearer $token';
await dio.post('/admin/clear-data', data: {'tables': tables});
if (!mounted) return;
setState(() => _selectedTables.clear());
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已清空:${labels.join('')}'),
backgroundColor: AppTheme.success,
),
);
} on DioException catch (e) {
if (!mounted) return;
final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ??
e.message ?? '未知错误';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('清空失败:$msg'), backgroundColor: AppTheme.danger),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('清空失败:$e'), backgroundColor: AppTheme.danger),
);
} finally {
if (mounted) setState(() => _clearing = false);
}
}
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) {
if (slot.isProcessing) {
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12, height: 12,
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary),
),
const SizedBox(width: 6),
Text(
'导入数据${slot.processingSeconds > 0 ? "${slot.processingSeconds}秒)" : ""}',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
),
],
);
} else {
statusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 80,
child: LinearProgressIndicator(
value: slot.uploadPercent / 100,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
minHeight: 6,
),
),
const SizedBox(width: 6),
Text(
'上传 ${slot.uploadPercent}%',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
);
}
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
],
),
const SizedBox(height: 3),
Padding(
padding: const EdgeInsets.only(left: 80),
child: Text(
slot.hint,
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
),
),
],
),
);
}
}
// ── 酒行信息行 ────────────────────────────────────────────
class _ShopInfoRow extends StatelessWidget {
final String label;
final String value;
const _ShopInfoRow({required this.label, required this.value});
@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: 14, color: AppTheme.textSecondary)),
),
Expanded(
child: Text(value,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w500)),
),
],
),
);
}
}
// ── 编辑酒行信息弹窗 ─────────────────────────────────────
class _ShopEditDialog extends ConsumerStatefulWidget {
final ShopInfo shop;
final VoidCallback onSaved;
const _ShopEditDialog({required this.shop, required this.onSaved});
@override
ConsumerState<_ShopEditDialog> createState() => _ShopEditDialogState();
}
class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
late final TextEditingController _nameCtrl;
late final TextEditingController _addressCtrl;
late final TextEditingController _phoneCtrl;
late final TextEditingController _managerCtrl;
bool _saving = false;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.shop.name);
_addressCtrl = TextEditingController(text: widget.shop.address);
_phoneCtrl = TextEditingController(text: widget.shop.phone);
_managerCtrl = TextEditingController(text: widget.shop.managerName);
}
@override
void dispose() {
_nameCtrl.dispose();
_addressCtrl.dispose();
_phoneCtrl.dispose();
_managerCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _saving = true);
try {
await ref.read(shopRepositoryProvider).updateInfo({
'name': _nameCtrl.text.trim(),
'address': _addressCtrl.text.trim(),
'phone': _phoneCtrl.text.trim(),
'manager_name': _managerCtrl.text.trim(),
});
if (mounted) {
Navigator.of(context).pop();
widget.onSaved();
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: const Text('编辑酒行信息'),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: '门店名称'),
),
const SizedBox(height: 12),
TextField(
controller: _addressCtrl,
decoration: const InputDecoration(labelText: '门店地址'),
),
const SizedBox(height: 12),
TextField(
controller: _phoneCtrl,
decoration: const InputDecoration(labelText: '联系电话'),
),
const SizedBox(height: 12),
TextField(
controller: _managerCtrl,
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('保存'),
),
],
);
}
}