Files
CloudHome-Ops/lib/screens/device/store_config_page.dart

555 lines
18 KiB
Dart
Raw Permalink 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 '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<StoreConfigPage> createState() => _StoreConfigPageState();
}
class _StoreConfigPageState extends ConsumerState<StoreConfigPage> {
final OrgService _orgService = OrgService();
final DeviceService _deviceService = DeviceService();
List<StoreInfo> _stores = [];
String? _selectedStoreId;
bool _loadingStores = true;
Map<String, dynamic>? _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<void> _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<void> _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<String, dynamic> 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<void> _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 = <String, dynamic>{'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<void> _resetPassword() async {
if (_selectedStoreId == null) return;
final confirmed = await showDialog<bool>(
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<String>(
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<bool>(
segments: const [
ButtonSegment(value: false, label: Text('否')),
ButtonSegment(value: true, label: Text('是')),
],
selected: {_disabled},
onSelectionChanged: (set) => setState(() => _disabled = set.first),
),
],
),
],
),
),
);
}
Future<void> _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)),
),
],
),
);
}
}