Files
jiu/client/lib/screens/settings/settings_screen.dart
T
wangjia 36c7ad8b43
Deploy Client / build-client-web (push) Successful in 38s
Deploy Client / build-windows (push) Successful in 1m52s
Deploy Client / build-macos (push) Successful in 1m55s
Deploy Client / build-android (push) Successful in 1m0s
Deploy Client / build-ios (push) Successful in 2m47s
Deploy Client / release-deploy-client (push) Successful in 1m21s
chore: release client-v1.0.61
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:02 +08:00

2136 lines
74 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:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_state.dart';
import '../../widgets/write_guard.dart';
import '../../core/config/app_config.dart';
import '../../core/config/app_constants.dart';
import '../../core/config/app_info.dart';
import '../../core/config/license_copy.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart';
import '../../models/number_rule.dart';
import '../../models/user.dart';
import '../../providers/license_provider.dart';
import '../../providers/number_rule_provider.dart';
import '../../providers/user_provider.dart';
import '../../providers/shop_provider.dart';
import '../../models/shop.dart';
class SettingsScreen extends ConsumerStatefulWidget {
/// 初始选中的 Tab(0=酒行信息 … 4=授权 … 5=数据管理)。
final int initialTab;
const SettingsScreen({super.key, this.initialTab = 0});
@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,
initialIndex: widget.initialTab,
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: [
_buildShopInfoTab(),
_buildUsersTab(),
_buildNumberRulesTab(),
_buildSystemParamsTab(),
_buildLicenseTab(),
_buildImportTab(),
],
),
),
],
),
);
}
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: [
// Logo 预览行
Row(
children: [
_ShopLogoPreview(logoUrl: shop.logoUrl, shopName: shop.name, size: 64),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(shop.name.isNotEmpty ? shop.name : '',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(shop.code.isNotEmpty ? shop.code : '',
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
],
),
],
),
const Divider(height: 24),
_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 : ''),
const Divider(height: 16),
_ShopInfoRow(
label: '微信号',
value: shop.wechatId.isNotEmpty ? shop.wechatId : ''),
],
),
),
),
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: [
WriteGuard(
child: 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: WriteGuard.isReadonly(ref)
? const []
: [
WriteGuard(
child: TextButton(
onPressed: () =>
_showEditUserDialog(context, u),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
),
WriteGuard(
child: TextButton(
onPressed: () =>
_showResetPasswordDialog(context, u),
child: const Text('重置密码',
style: TextStyle(fontSize: 12)),
),
),
],
)),
]))
.toList(),
),
),
),
),
],
);
}
// ── 授权 Tab ──────────────────────────────────────────────
Widget _buildLicenseTab() {
final licenseAsync = ref.watch(licenseProvider);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('授权信息',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
const Text('当前门店的授权状态与设备绑定',
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: licenseAsync.when(
loading: () => const _ParamRow(label: '授权状态', value: '加载中…'),
error: (_, __) =>
const _ParamRow(label: '授权状态', value: '暂无授权信息'),
data: (lic) => _buildLicenseContent(lic),
),
),
),
const SizedBox(height: 16),
// 过期降级说明
_buildExpiryNotes(),
const SizedBox(height: 16),
// 激活码输入区
_buildActivationCard(),
],
),
);
}
Widget _buildLicenseContent(LicenseInfo? lic) {
if (lic == null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _ParamRow(label: '授权状态', value: '未激活'),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _showRenewLicenseDialog,
icon: const Icon(Icons.card_membership, size: 16),
label: const Text('续费 / 获取授权码'),
),
],
);
}
final Color phaseColor = LicenseCopy.phaseColor(lic.phase);
final String phaseText = LicenseCopy.statusText(lic);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ParamRow(label: '授权类型', value: lic.typeLabel),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
const SizedBox(
width: 180,
child: Text('授权状态',
style: TextStyle(
fontSize: 14, color: AppTheme.textSecondary)),
),
Text(phaseText,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: phaseColor)),
],
),
),
if (lic.expiresAt != null)
_ParamRow(
label: '到期时间',
value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!))
else
const _ParamRow(label: '到期时间', value: '永久有效'),
_ParamRow(label: '设备上限', value: '${lic.maxDevices}'),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _showRenewLicenseDialog,
icon: const Icon(Icons.card_membership, size: 16),
label: const Text('续费 / 升级授权'),
),
],
);
}
Widget _buildExpiryNotes() {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Icon(Icons.info_outline,
size: 16, color: AppTheme.textSecondary),
SizedBox(width: 6),
Text('过期说明',
style:
TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 4),
const Text('授权到期后会分阶段降级,请在到期前及时续费',
style:
TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
const SizedBox(height: 12),
for (final note in LicenseCopy.degradationNotes())
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 6, right: 8),
child: Icon(Icons.circle,
size: 5, color: AppTheme.textSecondary),
),
Expanded(
child: Text(note,
style: const TextStyle(
fontSize: 13, height: 1.5)),
),
],
),
),
],
),
),
);
}
Widget _buildActivationCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('兑换激活码',
style:
TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
const Text('输入购买或活动获得的激活码,时长将叠加到当前授权之后',
style: TextStyle(
fontSize: 13, color: AppTheme.textSecondary)),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _licenseKeyCtrl,
decoration: const InputDecoration(
hintText: 'JIUKU-XXXX-XXXX',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
style: const TextStyle(fontSize: 13),
),
),
if (!WriteGuard.isReadonly(ref)) ...[
const SizedBox(width: 8),
ElevatedButton(
onPressed: _isActivating ? null : _activateLicense,
child: _isActivating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('兑换'),
),
],
],
),
],
),
),
);
}
late final TextEditingController _licenseKeyCtrl =
TextEditingController();
bool _isActivating = false;
Future<void> _activateLicense() async {
final key = _licenseKeyCtrl.text.trim();
if (key.isEmpty) return;
setState(() => _isActivating = true);
try {
final repo = ref.read(licenseRepositoryProvider);
await repo.activate(key);
_licenseKeyCtrl.clear();
await ref.read(licenseProvider.notifier).reload();
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.toString()),
backgroundColor: AppTheme.danger),
);
}
} finally {
if (mounted) setState(() => _isActivating = false);
}
}
void _showRenewLicenseDialog() {
showAppDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('续费 / 升级授权'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('请联系我们获取续费报价:'),
const SizedBox(height: 12),
SelectableText('📧 ${AppInfo.email}',
style: const TextStyle(fontSize: 13)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('关闭'),
),
ElevatedButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: AppInfo.email));
if (ctx.mounted) {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('邮箱已复制到剪贴板')),
);
}
},
child: const Text('复制邮箱'),
),
],
),
);
}
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(WriteGuard(
child: 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: context.dialogWidth(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() {
if (WriteGuard.isReadonly(ref)) {
return const Center(
child: Text('只读账号无导入权限',
style: TextStyle(color: AppTheme.textSecondary)),
);
}
if (WriteGuard.licenseBlocked(ref)) {
final lic = ref.watch(licenseProvider).valueOrNull;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
lic == null ? '授权已过期,暂时无法导入' : LicenseCopy.writeBlockedToast(lic),
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.textSecondary),
),
),
);
}
final currentUser = ref.watch(authStateProvider).user;
final isSuperAdmin = currentUser?.role == 'superadmin';
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
}
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: context.dialogWidth(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 _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: context.dialogWidth(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: context.dialogWidth(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)),
),
],
),
);
}
}
// ── 批量数据导入 ───────────────────────────────────────────
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 updated = 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/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: AppConstants.importSendTimeout,
receiveTimeout: AppConstants.importReceiveTimeout,
));
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.imported = (data['imported'] ?? 0) as int;
slot.skipped = (data['skipped'] ?? 0) as int;
slot.updated = (data['updated'] ?? 0) as int;
slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) 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: context.dialogWidth(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 duplicate = slot.skipped + slot.updated;
final parts = <String>[
'${slot.total}',
'新增 ${slot.imported}',
if (duplicate > 0) '重复 $duplicate',
];
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),
),
),
],
),
);
}
}
// ── 门店 Logo 预览 ────────────────────────────────────────
class _ShopLogoPreview extends StatelessWidget {
final String logoUrl;
final String shopName;
final double size;
const _ShopLogoPreview({required this.logoUrl, required this.shopName, this.size = 64});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(size * 0.19);
if (logoUrl.isNotEmpty) {
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
return ClipRRect(
borderRadius: radius,
child: Image.network(
fullUrl,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _initial(radius),
),
);
}
return _initial(radius);
}
Widget _initial(BorderRadius radius) {
final initial = shopName.isNotEmpty ? shopName.characters.first : '';
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFF0F3057),
borderRadius: radius,
),
alignment: Alignment.center,
child: Text(
initial,
style: TextStyle(
color: Colors.white,
fontSize: size * 0.5,
fontWeight: FontWeight.w600,
),
),
);
}
}
// ── 酒行信息行 ────────────────────────────────────────────
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;
late final TextEditingController _wechatCtrl;
bool _saving = false;
bool _uploadingLogo = 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);
_wechatCtrl = TextEditingController(text: widget.shop.wechatId);
}
@override
void dispose() {
_nameCtrl.dispose();
_addressCtrl.dispose();
_phoneCtrl.dispose();
_managerCtrl.dispose();
_wechatCtrl.dispose();
super.dispose();
}
Future<void> _uploadLogo() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png'],
withData: true,
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
if (file.bytes == null) return;
setState(() => _uploadingLogo = true);
try {
await ref.read(shopRepositoryProvider).uploadLogo(file.bytes!, file.name);
ref.invalidate(shopInfoProvider);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Logo 已更新'),
backgroundColor: AppTheme.success,
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('上传失败:$e'),
backgroundColor: AppTheme.danger,
));
}
} finally {
if (mounted) setState(() => _uploadingLogo = false);
}
}
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(),
'wechat_id': _wechatCtrl.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: context.dialogWidth(400),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo 上传行
Row(
children: [
_ShopLogoPreview(
logoUrl: ref.watch(shopInfoProvider).valueOrNull?.logoUrl ?? widget.shop.logoUrl,
shopName: _nameCtrl.text,
size: 56,
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _uploadingLogo ? null : _uploadLogo,
icon: _uploadingLogo
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.upload_outlined, size: 16),
label: const Text('更换 Logo'),
),
],
),
const SizedBox(height: 16),
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: '负责人'),
),
const SizedBox(height: 12),
TextField(
controller: _wechatCtrl,
decoration: const InputDecoration(
labelText: '微信号(可选)',
hintText: '填写后顾客扫码可查看',
),
),
],
),
),
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('保存'),
),
],
);
}
}