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
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2136 lines
74 KiB
Dart
2136 lines
74 KiB
Dart
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('保存'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|