import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/store_info.dart'; import '../../services/api_client.dart'; import '../../services/device_service.dart'; import '../../services/org_service.dart'; import '../../utils/formatters.dart'; /// 门店配置页:门店选择、配置表单、保存/重置密码。 class StoreConfigPage extends ConsumerStatefulWidget { const StoreConfigPage({super.key}); @override ConsumerState createState() => _StoreConfigPageState(); } class _StoreConfigPageState extends ConsumerState { final OrgService _orgService = OrgService(); final DeviceService _deviceService = DeviceService(); List _stores = []; String? _selectedStoreId; bool _loadingStores = true; Map? _config; bool _loading = false; bool _saving = false; bool _disabled = false; String? _error; // 表单控制器 final _defaultVolumeController = TextEditingController(); final _heartbeatIntervalController = TextEditingController(); final _storePasswordController = TextEditingController(); final _maxVolumeController = TextEditingController(); final _offlineThresholdController = TextEditingController(); final _mediaVolumeController = TextEditingController(); final _expireTimeController = TextEditingController(); @override void initState() { super.initState(); _loadStores(); } @override void dispose() { _defaultVolumeController.dispose(); _heartbeatIntervalController.dispose(); _storePasswordController.dispose(); _maxVolumeController.dispose(); _offlineThresholdController.dispose(); _mediaVolumeController.dispose(); _expireTimeController.dispose(); super.dispose(); } Future _loadStores() async { try { final stores = await _orgService.getMyStores(); if (!mounted) return; setState(() { _stores = stores; _loadingStores = false; if (stores.isNotEmpty) { _selectedStoreId = stores.first.orgId; } }); if (_selectedStoreId != null) { _loadConfig(); } } catch (e) { if (!mounted) return; setState(() => _loadingStores = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('门店列表加载失败:$e')), ); } } Future _loadConfig() async { if (_selectedStoreId == null) return; setState(() { _loading = true; _error = null; }); try { final config = await _deviceService.getStoreConfig(_selectedStoreId!); if (!mounted) return; _populateControllers(config); setState(() { _config = config; _loading = false; }); } catch (e) { if (!mounted) return; final friendly = formatApiError(e, fallback: '门店配置加载失败,请稍后重试'); setState(() { _error = '$friendly\n\n若持续出现「未知异常」,请联系后端排查 /device/storeConfig/info/{orgId} 接口。'; _loading = false; }); } } void _populateControllers(Map config) { // 字段名以 Web 后台 StoreConfig 模型为准(systemDefaultVolume / password), // 同时兼容旧移动端字段名(defaultVolume / storePassword),避免回显为空。 _defaultVolumeController.text = _toString(config['systemDefaultVolume'] ?? config['defaultVolume']); _heartbeatIntervalController.text = _toString(config['heartbeatInterval']); _storePasswordController.text = _toString(config['password'] ?? config['storePassword']); _maxVolumeController.text = _toString(config['maxVolume']); _offlineThresholdController.text = _toString(config['offlineThreshold']); _mediaVolumeController.text = _toString(config['mediaVolume'] ?? config['media_volume']); _expireTimeController.text = _toString(config['expireTime']); _disabled = _parseDisabled(config['disabled']); } /// 解析禁用状态:true / 非零 num / 字符串 '1' 视为禁用=true,其余 false。 static bool _parseDisabled(dynamic value) { if (value == true) return true; if (value is num) return value != 0; if (value is String) return value.trim() == '1'; return false; } static String _toString(dynamic value) { if (value == null) return ''; return value.toString(); } Future _save() async { if (_selectedStoreId == null) return; setState(() => _saving = true); try { // 字段名以 Web 后台 StoreConfig 模型为准(见 screen-front src/api/device.ts): // systemDefaultVolume / maxVolume / heartbeatInterval / password / expireTime。 // 仅发送非空字段,避免把后端既有值覆盖为空;密码留空则不传(与 Web 一致, // 防止误清空)。offlineThreshold 为旧移动端遗留字段,后端忽略无害,保留发送。 final data = {'orgId': _selectedStoreId}; final sysDef = _defaultVolumeController.text.trim(); final vSysDef = int.tryParse(sysDef); if (sysDef.isNotEmpty && vSysDef != null) { data['systemDefaultVolume'] = vSysDef; } final max = _maxVolumeController.text.trim(); final vMax = int.tryParse(max); if (max.isNotEmpty && vMax != null) data['maxVolume'] = vMax; final hb = _heartbeatIntervalController.text.trim(); final vHb = int.tryParse(hb); if (hb.isNotEmpty && vHb != null) data['heartbeatInterval'] = vHb; final off = _offlineThresholdController.text.trim(); final vOff = int.tryParse(off); if (off.isNotEmpty && vOff != null) data['offlineThreshold'] = vOff; final pwd = _storePasswordController.text.trim(); if (pwd.isNotEmpty) data['password'] = pwd; final exp = _expireTimeController.text.trim(); if (exp.isNotEmpty) data['expireTime'] = exp; final media = _mediaVolumeController.text.trim(); final vMedia = int.tryParse(media); if (media.isNotEmpty && vMedia != null) data['mediaVolume'] = vMedia; // disabled 始终发送当前开关状态(布尔值),确保与 Web 后台一致。 data['disabled'] = _disabled; await _deviceService.createOrUpdateStoreConfig(data); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('配置已保存')), ); _loadConfig(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('保存失败:${formatApiError(e)}')), ); } finally { if (mounted) setState(() => _saving = false); } } Future _resetPassword() async { if (_selectedStoreId == null) return; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('重置密码'), content: const Text('确定要重置门店配置密码吗?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.pop(ctx, true), style: TextButton.styleFrom(foregroundColor: Colors.orange), child: const Text('重置'), ), ], ), ); if (confirmed != true) return; try { await _deviceService.resetStoreConfigPassword(_selectedStoreId!); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('密码已重置')), ); _loadConfig(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('重置失败:${formatApiError(e)}')), ); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( automaticallyImplyLeading: false, title: const Text('门店配置'), ), body: Column( children: [ _buildStoreSelector(), const Divider(height: 1), Expanded(child: _buildBody()), ], ), ); } Widget _buildStoreSelector() { if (_loadingStores) { return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ); } if (_stores.isEmpty) { return const Padding( padding: EdgeInsets.all(16), child: Text('暂无可选门店'), ); } return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: DropdownButtonFormField( value: _selectedStoreId, decoration: const InputDecoration( labelText: '选择门店', border: OutlineInputBorder(), prefixIcon: Icon(Icons.store), ), items: _stores .map((s) => DropdownMenuItem( value: s.orgId, child: Text(s.name), )) .toList(), onChanged: (v) { setState(() => _selectedStoreId = v); _loadConfig(); }, ), ); } Widget _buildBody() { if (_selectedStoreId == null) { return const Center(child: Text('请先选择门店')); } if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_error != null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('加载失败:$_error'), const SizedBox(height: 8), FilledButton.tonal( onPressed: _loadConfig, child: const Text('重试'), ), ], ), ); } if (_config == null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.settings_outlined, size: 64, color: Theme.of(context).colorScheme.outline), const SizedBox(height: 16), const Text('暂无配置信息'), const SizedBox(height: 16), FilledButton.icon( onPressed: _save, icon: const Icon(Icons.add), label: const Text('创建配置'), ), ], ), ); } return RefreshIndicator( onRefresh: _loadConfig, child: ListView( padding: const EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(), children: [ _buildConfigForm(), const SizedBox(height: 16), _buildExtraInfo(), const SizedBox(height: 24), _buildActions(), ], ), ); } Widget _buildConfigForm() { return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '配置项', style: Theme.of(context) .textTheme .titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 16), TextFormField( controller: _defaultVolumeController, decoration: const InputDecoration( labelText: '系统默认音量', border: OutlineInputBorder(), prefixIcon: Icon(Icons.volume_up), helperText: '设备系统默认音量 (0-100)', ), keyboardType: TextInputType.number, ), const SizedBox(height: 12), TextFormField( controller: _mediaVolumeController, decoration: const InputDecoration( labelText: '媒体区域音量', border: OutlineInputBorder(), prefixIcon: Icon(Icons.volume_down), helperText: '媒体播放区域音量 (0-100)', ), keyboardType: TextInputType.number, ), const SizedBox(height: 12), TextFormField( controller: _maxVolumeController, decoration: const InputDecoration( labelText: '最大音量', border: OutlineInputBorder(), prefixIcon: Icon(Icons.volume_up_sharp), helperText: '设备允许的最大音量', ), keyboardType: TextInputType.number, ), const SizedBox(height: 12), TextFormField( controller: _heartbeatIntervalController, decoration: const InputDecoration( labelText: '心跳间隔 (秒)', border: OutlineInputBorder(), prefixIcon: Icon(Icons.favorite), helperText: '设备心跳上报间隔', ), keyboardType: TextInputType.number, ), const SizedBox(height: 12), TextFormField( controller: _offlineThresholdController, decoration: const InputDecoration( labelText: '离线阈值 (秒)', border: OutlineInputBorder(), prefixIcon: Icon(Icons.timer_off), helperText: '超过此时间无心跳则判定离线', ), keyboardType: TextInputType.number, ), const SizedBox(height: 12), TextFormField( controller: _storePasswordController, decoration: const InputDecoration( labelText: '门店密码', border: OutlineInputBorder(), prefixIcon: Icon(Icons.lock), helperText: '设备绑定门店时的验证密码', ), obscureText: true, ), const SizedBox(height: 12), TextFormField( controller: _expireTimeController, decoration: InputDecoration( labelText: '到期时间', border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.event), helperText: '留空则不修改;格式 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss', suffixIcon: IconButton( icon: const Icon(Icons.calendar_month), tooltip: '选择日期', onPressed: _saving ? null : _pickExpireDate, ), ), keyboardType: TextInputType.datetime, ), const SizedBox(height: 12), Row( children: [ const SizedBox(width: 84, child: Text('禁用')), SegmentedButton( segments: const [ ButtonSegment(value: false, label: Text('否')), ButtonSegment(value: true, label: Text('是')), ], selected: {_disabled}, onSelectionChanged: (set) => setState(() => _disabled = set.first), ), ], ), ], ), ), ); } Future _pickExpireDate() async { final current = _expireTimeController.text.trim(); final initial = DateTime.tryParse(current) ?? DateTime.tryParse(current.replaceFirst(' ', 'T')) ?? DateTime.now(); final picked = await showDatePicker( context: context, initialDate: initial, firstDate: DateTime(2000), lastDate: DateTime(2100), ); if (picked != null && mounted) { setState(() => _expireTimeController.text = picked.toIso8601String().substring(0, 10)); } } Widget _buildExtraInfo() { final theme = Theme.of(context); final createTime = _config?['createTime']; final updateTime = _config?['updateTime']; String _fmt(dynamic t) { if (t == null) return '—'; final text = t.toString().trim(); if (text.isEmpty) return '—'; final dt = DateTime.tryParse(text) ?? DateTime.tryParse(text.replaceFirst(' ', 'T')); return dt != null ? Formatters.formatDateTime(dt) : text; } return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '其他信息', style: theme.textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), _infoRow(theme, '创建时间', _fmt(createTime)), _infoRow(theme, '更新时间', _fmt(updateTime)), ], ), ), ); } Widget _buildActions() { return Row( children: [ Expanded( child: FilledButton.icon( onPressed: _saving ? null : _save, icon: _saving ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save), label: const Text('保存配置'), ), ), const SizedBox(width: 12), Expanded( child: OutlinedButton.icon( onPressed: _resetPassword, style: OutlinedButton.styleFrom( foregroundColor: Colors.orange, ), icon: const Icon(Icons.password), label: const Text('重置密码'), ), ), ], ); } Widget _infoRow(ThemeData theme, String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ SizedBox( width: 80, child: Text( label, style: theme.textTheme.bodyMedium ?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), Expanded( child: Text(value, style: theme.textTheme.bodyMedium ?.copyWith(fontWeight: FontWeight.w600)), ), ], ), ); } }