ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
107
lib/widgets/app_bottom_nav.dart
Normal file
107
lib/widgets/app_bottom_nav.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../utils/i10n.dart';
|
||||
import '../utils/responsive.dart';
|
||||
|
||||
/// 底部导航栏(配合 [StatefulShellRoute] 使用)。
|
||||
///
|
||||
/// 五个 tab:首页、设备、门店、管理、我的。
|
||||
class AppBottomNav extends StatelessWidget {
|
||||
const AppBottomNav({super.key, required this.shell});
|
||||
|
||||
/// go_router 的导航壳。
|
||||
final StatefulNavigationShell shell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
|
||||
final railDestinations = <NavigationRailDestination>[
|
||||
NavigationRailDestination(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
selectedIcon: const Icon(Icons.home),
|
||||
label: Text(t.home),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: const Icon(Icons.devices_outlined),
|
||||
selectedIcon: const Icon(Icons.devices),
|
||||
label: Text(t.devices),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: const Icon(Icons.store_outlined),
|
||||
selectedIcon: const Icon(Icons.store),
|
||||
label: Text(t.stores),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined),
|
||||
selectedIcon: const Icon(Icons.admin_panel_settings),
|
||||
label: Text(t.admin),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: const Icon(Icons.person_outline),
|
||||
selectedIcon: const Icon(Icons.person),
|
||||
label: Text(t.profile),
|
||||
),
|
||||
];
|
||||
|
||||
void onSelect(int index) => shell.goBranch(
|
||||
index,
|
||||
// 点击当前 tab 时回到该分支根路由。
|
||||
initialLocation: index == shell.currentIndex,
|
||||
);
|
||||
|
||||
// 横屏:改用左侧 NavigationRail,腾出底部 80dp 纵向空间。
|
||||
if (context.responsive.isLandscape) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: shell.currentIndex,
|
||||
onDestinationSelected: onSelect,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: railDestinations,
|
||||
),
|
||||
const VerticalDivider(width: 1, thickness: 1),
|
||||
Expanded(child: shell),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: shell,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: shell.currentIndex,
|
||||
onDestinationSelected: onSelect,
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
selectedIcon: const Icon(Icons.home),
|
||||
label: t.home,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.devices_outlined),
|
||||
selectedIcon: const Icon(Icons.devices),
|
||||
label: t.devices,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.store_outlined),
|
||||
selectedIcon: const Icon(Icons.store),
|
||||
label: t.stores,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined),
|
||||
selectedIcon: const Icon(Icons.admin_panel_settings),
|
||||
label: t.admin,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.person_outline),
|
||||
selectedIcon: const Icon(Icons.person),
|
||||
label: t.profile,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/widgets/app_bottom_sheet.dart
Normal file
75
lib/widgets/app_bottom_sheet.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/theme.dart';
|
||||
|
||||
/// 统一底部弹窗封装:一次性解决「无把手 / 无安全区 / 无限高 / 保存按钮埋在滚动区」。
|
||||
///
|
||||
/// - 顶部拖拽把手([BottomSheetThemeData.showDragHandle] 已全局兜底,这里显式再设一次)。
|
||||
/// - 安全区(不被手势条遮挡)。
|
||||
/// - 限宽 640 居中(横屏不铺满)。
|
||||
/// - [footer] 吸底操作栏始终可见,[body] 内部独立滚动。
|
||||
///
|
||||
/// 详见 docs/手机端UI优化方案-v2.md §8.4。
|
||||
Future<T?> showAppBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
required Widget body,
|
||||
Widget? footer,
|
||||
String? title,
|
||||
double heightFactor = 0.92,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
useSafeArea: true,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 640,
|
||||
maxHeight: MediaQuery.sizeOf(context).height * heightFactor,
|
||||
),
|
||||
builder: (ctx) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(ctx)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
0,
|
||||
20,
|
||||
20 + MediaQuery.viewInsetsOf(ctx).bottom,
|
||||
),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
if (footer != null)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(ctx).colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(ctx).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
boxShadow: AppShadows.lg,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||
// 吸底栏不受滚动影响,但底部手势条区域交给 SafeArea 处理。
|
||||
child: SafeArea(top: false, child: footer),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
173
lib/widgets/branding_editor.dart
Normal file
173
lib/widgets/branding_editor.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
/// 品牌配置编辑(启动图 + Logo)。
|
||||
///
|
||||
/// 通过 [ImagePicker] 选择本地图片,并用「手机外框」预览:
|
||||
/// - 启动图为全屏背景;
|
||||
/// - Logo 为右下角角标。
|
||||
///
|
||||
/// [splashUrl]/[logoUrl] 为本地图片路径(占位),由父级保存。
|
||||
class BrandingEditor extends StatefulWidget {
|
||||
const BrandingEditor({
|
||||
super.key,
|
||||
this.initialSplashUrl,
|
||||
this.initialLogoUrl,
|
||||
});
|
||||
|
||||
final String? initialSplashUrl;
|
||||
final String? initialLogoUrl;
|
||||
|
||||
@override
|
||||
State<BrandingEditor> createState() => BrandingEditorState();
|
||||
}
|
||||
|
||||
/// 品牌编辑器状态(公开,供父级通过 GlobalKey 读取所选图片路径)。
|
||||
class BrandingEditorState extends State<BrandingEditor> {
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
String? _splashUrl;
|
||||
String? _logoUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_splashUrl = widget.initialSplashUrl;
|
||||
_logoUrl = widget.initialLogoUrl;
|
||||
}
|
||||
|
||||
Future<void> _pickSplash() async {
|
||||
final xFile = await _picker.pickImage(source: ImageSource.gallery);
|
||||
if (xFile != null && mounted) {
|
||||
setState(() => _splashUrl = xFile.path);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickLogo() async {
|
||||
final xFile = await _picker.pickImage(source: ImageSource.gallery);
|
||||
if (xFile != null && mounted) {
|
||||
setState(() => _logoUrl = xFile.path);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _pickSplash,
|
||||
child: const Text('选择启动图'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _pickLogo,
|
||||
child: const Text('选择 Logo'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 手机外框预览。
|
||||
Center(
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 380,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: Colors.black87, width: 8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 启动图全屏背景。
|
||||
if (_splashUrl != null && _splashUrl!.isNotEmpty)
|
||||
Positioned.fill(
|
||||
child: Image.network(
|
||||
_splashUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'启动图预览',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 顶部状态栏占位。
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: 18,
|
||||
color: Colors.black.withOpacity(0.25),
|
||||
),
|
||||
),
|
||||
// Logo 右下角角标。
|
||||
if (_logoUrl != null && _logoUrl!.isNotEmpty)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 24,
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black26,
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
_logoUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const Icon(Icons.image, size: 28),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 当前启动图本地路径(未选返回 null)。
|
||||
String? get splashUrl => _splashUrl;
|
||||
|
||||
/// 当前 Logo 本地路径(未选返回 null)。
|
||||
String? get logoUrl => _logoUrl;
|
||||
}
|
||||
48
lib/widgets/cloud_card.dart
Normal file
48
lib/widgets/cloud_card.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/theme.dart';
|
||||
|
||||
/// 品牌云卡:橙→金渐变表面 + 深棕文字,用于首页 / 概览的招牌区块。
|
||||
///
|
||||
/// 自动把内部文字与图标设为 [AppBrand.onCloud](亮橙底上的深棕),
|
||||
/// 因此卡片内无需再单独指定前景色。
|
||||
class CloudCard extends StatelessWidget {
|
||||
const CloudCard({
|
||||
super.key,
|
||||
this.child,
|
||||
this.padding = const EdgeInsets.all(20),
|
||||
this.radius,
|
||||
});
|
||||
|
||||
final Widget? child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
/// 圆角覆盖;默认用 [AppRadius.lg]。
|
||||
final double? radius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppBrand.cloudGradient,
|
||||
borderRadius: BorderRadius.circular(radius ?? AppRadius.lg),
|
||||
boxShadow: AppShadows.md,
|
||||
),
|
||||
padding: padding,
|
||||
foregroundDecoration: scheme.brightness == Brightness.dark
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(radius ?? AppRadius.lg),
|
||||
color: Colors.black.withOpacity(0.28),
|
||||
)
|
||||
: null,
|
||||
child: DefaultTextStyle(
|
||||
style: const TextStyle(color: AppBrand.onCloud),
|
||||
child: IconTheme(
|
||||
data: const IconThemeData(color: AppBrand.onCloud),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
441
lib/widgets/device_command_sheet.dart
Normal file
441
lib/widgets/device_command_sheet.dart
Normal file
@@ -0,0 +1,441 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/device.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/device_service.dart';
|
||||
import '../utils/i10n.dart';
|
||||
|
||||
/// 指令参数项。
|
||||
///
|
||||
/// [key] **必须与 Web 后台 `src/api/device.ts` 的 `commandTypeOptions`
|
||||
/// 参数 key 完全一致**,否则后端取不到值,设备端会因缺少参数而忽略该指令。
|
||||
/// 当前约定:
|
||||
/// - `passwordUpdate` → `passwd`
|
||||
/// - `mediaZoneVol` → `vol`
|
||||
/// - `defaultVol` → `vol` + `maxVol`
|
||||
class _CommandParam {
|
||||
const _CommandParam(this.key, this.label, {this.numeric = false});
|
||||
|
||||
/// 提交给后端的参数名。
|
||||
final String key;
|
||||
|
||||
/// 输入框标签。
|
||||
final String label;
|
||||
|
||||
/// 是否为 0~100 的整数(音量类参数)。
|
||||
final bool numeric;
|
||||
}
|
||||
|
||||
/// 设备指令项。
|
||||
class _CommandItem {
|
||||
const _CommandItem(this.value, this.label, {this.params = const []});
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
/// 需要用户填写的参数;为空表示无需输入,直接确认后下发。
|
||||
final List<_CommandParam> params;
|
||||
|
||||
bool get needsInput => params.isNotEmpty;
|
||||
}
|
||||
|
||||
/// 设备指令下发弹窗。
|
||||
///
|
||||
/// 两种用法:
|
||||
/// 1. 单设备:传 [device],指令按 MAC 下发([DeviceService.sendCommandByMac]);
|
||||
/// 2. 门店批量:传 [orgId](不传 [device]),指令按组织下发
|
||||
/// ([DeviceService.sendCommandByOrg]),影响该门店下全部设备。
|
||||
class DeviceCommandSheet extends StatefulWidget {
|
||||
const DeviceCommandSheet({
|
||||
super.key,
|
||||
this.device,
|
||||
this.orgId,
|
||||
this.orgName,
|
||||
required this.deviceService,
|
||||
}) : assert(device != null || orgId != null,
|
||||
'device 与 orgId 至少传其一');
|
||||
|
||||
/// 单设备模式下的目标设备。
|
||||
final Device? device;
|
||||
|
||||
/// 门店批量模式下的组织 ID。
|
||||
final String? orgId;
|
||||
|
||||
/// 门店批量模式下的组织名称(仅用于展示)。
|
||||
final String? orgName;
|
||||
|
||||
final DeviceService deviceService;
|
||||
|
||||
/// 是否为门店批量模式。
|
||||
bool get isOrgMode => orgId != null && orgId!.isNotEmpty;
|
||||
|
||||
@override
|
||||
State<DeviceCommandSheet> createState() => _DeviceCommandSheetState();
|
||||
}
|
||||
|
||||
class _DeviceCommandSheetState extends State<DeviceCommandSheet> {
|
||||
final List<TextEditingController> _paramControllers = [];
|
||||
bool _sending = false;
|
||||
|
||||
/// 设备心跳超过该分钟数即视为「可能离线」,下发前给出提示。
|
||||
static const int _heartbeatStaleMinutes = 3;
|
||||
|
||||
List<_CommandItem> _commands(BuildContext context) {
|
||||
final t = AppLocalizations.of(context);
|
||||
return [
|
||||
_CommandItem(DeviceCommand.powerOff, t.deviceCommandLabel(DeviceCommand.powerOff)),
|
||||
_CommandItem(DeviceCommand.reboot, t.deviceCommandLabel(DeviceCommand.reboot)),
|
||||
_CommandItem(DeviceCommand.screenshot, t.deviceCommandLabel(DeviceCommand.screenshot)),
|
||||
_CommandItem(DeviceCommand.themeUpdate, t.deviceCommandLabel(DeviceCommand.themeUpdate)),
|
||||
_CommandItem(DeviceCommand.versionUpdate, t.deviceCommandLabel(DeviceCommand.versionUpdate)),
|
||||
_CommandItem(DeviceCommand.marqueeUpdate, t.deviceCommandLabel(DeviceCommand.marqueeUpdate)),
|
||||
_CommandItem(DeviceCommand.weatherUpdate, t.deviceCommandLabel(DeviceCommand.weatherUpdate)),
|
||||
_CommandItem(
|
||||
DeviceCommand.mediaZoneVol,
|
||||
t.deviceCommandLabel(DeviceCommand.mediaZoneVol),
|
||||
params: [
|
||||
_CommandParam('vol', t.deviceCommandInputLabel(DeviceCommand.mediaZoneVol),
|
||||
numeric: true),
|
||||
],
|
||||
),
|
||||
_CommandItem(
|
||||
DeviceCommand.defaultVol,
|
||||
t.deviceCommandLabel(DeviceCommand.defaultVol),
|
||||
params: [
|
||||
_CommandParam('vol', t.deviceCommandInputLabel(DeviceCommand.defaultVol),
|
||||
numeric: true),
|
||||
_CommandParam('maxVol', t.maxVolume, numeric: true),
|
||||
],
|
||||
),
|
||||
_CommandItem(DeviceCommand.themeClear, t.deviceCommandLabel(DeviceCommand.themeClear)),
|
||||
_CommandItem(DeviceCommand.cacheClear, t.deviceCommandLabel(DeviceCommand.cacheClear)),
|
||||
_CommandItem(DeviceCommand.storeExpire, t.deviceCommandLabel(DeviceCommand.storeExpire)),
|
||||
_CommandItem(DeviceCommand.storeDisable, t.deviceCommandLabel(DeviceCommand.storeDisable)),
|
||||
_CommandItem(
|
||||
DeviceCommand.passwordUpdate,
|
||||
t.deviceCommandLabel(DeviceCommand.passwordUpdate),
|
||||
params: [
|
||||
_CommandParam('passwd', t.deviceCommandInputLabel(DeviceCommand.passwordUpdate)),
|
||||
],
|
||||
),
|
||||
_CommandItem(DeviceCommand.worldAd, t.deviceCommandLabel(DeviceCommand.worldAd)),
|
||||
];
|
||||
}
|
||||
|
||||
/// 设备是否「可能离线」:状态为 offline,或心跳时间早于阈值。
|
||||
///
|
||||
/// 用于下发前提示,不改变下发行为(仍可强制下发)。
|
||||
bool _isLikelyOffline(Device device) {
|
||||
if (device.status == DeviceStatus.offline) return true;
|
||||
final heartbeat = device.lastSyncTime;
|
||||
if (heartbeat == null) return true;
|
||||
return DateTime.now().difference(heartbeat).inMinutes > _heartbeatStaleMinutes;
|
||||
}
|
||||
|
||||
Future<void> _send(_CommandItem item) async {
|
||||
final t = AppLocalizations.of(context);
|
||||
final data = <String, dynamic>{};
|
||||
if (item.needsInput) {
|
||||
for (var i = 0; i < item.params.length; i++) {
|
||||
final param = item.params[i];
|
||||
final value = _paramControllers[i].text.trim();
|
||||
if (value.isEmpty) {
|
||||
// maxVol 允许留空(后端对该项有默认值),其余参数必填。
|
||||
if (param.key == 'maxVol') continue;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${t.pleaseInput}${param.label}')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (param.numeric) {
|
||||
final vol = int.tryParse(value);
|
||||
if (vol == null || vol < 0 || vol > 100) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.volumeRangeHint)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 与 Web 端 `data[key] = item.value.trim()` 保持一致:统一传字符串,
|
||||
// 避免后端按 String 解析数字时抛类型转换异常。
|
||||
data[param.key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
if (widget.isOrgMode) {
|
||||
await widget.deviceService.sendCommandByOrg(
|
||||
orgId: widget.orgId!,
|
||||
command: item.value,
|
||||
data: data.isNotEmpty ? data : null,
|
||||
);
|
||||
} else {
|
||||
final mac = widget.device!.mac.trim();
|
||||
if (mac.isEmpty) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.commandMacMissing)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await widget.deviceService.sendCommandByMac(
|
||||
mac: mac,
|
||||
command: item.value,
|
||||
data: data.isNotEmpty ? data : null,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
final target = widget.isOrgMode
|
||||
? '${t.belongStore}「${widget.orgName ?? widget.orgId}」'
|
||||
: '${t.deviceDetails}「${widget.device!.macLabel}」';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.commandSent(target, item.label))),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${t.commandSendFailed}:${formatApiError(e)}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _paramControllers) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final t = AppLocalizations.of(context);
|
||||
final commands = _commands(context);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.isOrgMode ? t.sendOrgCommandTitle : t.sendCommandTitle,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (widget.isOrgMode) ...[
|
||||
Text(
|
||||
'${t.belongStore}:${widget.orgName ?? widget.orgId}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${t.commandType}:${t.allStores}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Text(
|
||||
'${t.deviceDetails}:${widget.device!.macLabel}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${t.macAddress}:${widget.device!.mac}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_buildHeartbeatLine(t),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: widget.device != null && _isLikelyOffline(widget.device!)
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
t.commandType,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: commands.map((item) {
|
||||
return ActionChip(
|
||||
label: Text(item.label),
|
||||
onPressed: _sending ? null : () => _onCommandTap(item),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildHeartbeatLine(AppLocalizations t) {
|
||||
final device = widget.device;
|
||||
if (device == null) return '';
|
||||
final heartbeat = device.lastSyncTime;
|
||||
if (heartbeat == null) {
|
||||
return '${t.lastActiveTime}:${t.deviceSubtitleNotSet}';
|
||||
}
|
||||
final minutes = DateTime.now().difference(heartbeat).inMinutes;
|
||||
return '${t.lastActiveTime}:${_format(heartbeat)}($minutes${t.minutesAgo})';
|
||||
}
|
||||
|
||||
String _format(DateTime time) {
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${time.year}-${two(time.month)}-${two(time.day)} '
|
||||
'${two(time.hour)}:${two(time.minute)}';
|
||||
}
|
||||
|
||||
void _onCommandTap(_CommandItem item) {
|
||||
if (!item.needsInput) {
|
||||
_showConfirm(item);
|
||||
return;
|
||||
}
|
||||
_showInputDialog(item);
|
||||
}
|
||||
|
||||
/// 下发前校验:单设备模式下若设备离线/心跳超时,先给出警告。
|
||||
Future<bool> _confirmIfOffline() async {
|
||||
final device = widget.device;
|
||||
final t = AppLocalizations.of(context);
|
||||
if (device == null || !_isLikelyOffline(device)) return true;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(t.commandConfirmTitle),
|
||||
content: Text(t.commandOfflineWarning),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(t.commandOfflineConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
void _showConfirm(_CommandItem item) async {
|
||||
final t = AppLocalizations.of(context);
|
||||
final proceed = await _confirmIfOffline();
|
||||
if (!proceed || !mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(t.commandConfirmTitle),
|
||||
content: Text(t.commandConfirmContent(item.label)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _sending
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(ctx);
|
||||
_send(item);
|
||||
},
|
||||
child: _sending
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(t.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showInputDialog(_CommandItem item) async {
|
||||
final t = AppLocalizations.of(context);
|
||||
final proceed = await _confirmIfOffline();
|
||||
if (!proceed || !mounted) return;
|
||||
for (final c in _paramControllers) {
|
||||
c.dispose();
|
||||
}
|
||||
_paramControllers
|
||||
..clear()
|
||||
..addAll(List.generate(item.params.length, (_) => TextEditingController()));
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(item.label),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(item.params.length, (i) {
|
||||
final param = item.params[i];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: i == item.params.length - 1 ? 0 : 12,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _paramControllers[i],
|
||||
decoration: InputDecoration(
|
||||
labelText: param.label,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType:
|
||||
param.numeric ? TextInputType.number : TextInputType.text,
|
||||
obscureText: param.key == 'passwd',
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: Text(t.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _sending
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(ctx);
|
||||
_send(item);
|
||||
},
|
||||
child: _sending
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(t.sendCommand),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/widgets/device_list_tile.dart
Normal file
90
lib/widgets/device_list_tile.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/device.dart';
|
||||
import '../services/device_service.dart';
|
||||
import '../utils/i10n.dart';
|
||||
import 'device_command_sheet.dart';
|
||||
import 'status_chip.dart';
|
||||
|
||||
/// 设备列表项。
|
||||
class DeviceListTile extends StatelessWidget {
|
||||
const DeviceListTile({
|
||||
super.key,
|
||||
required this.device,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// 设备数据。
|
||||
final Device device;
|
||||
|
||||
/// 点击回调。
|
||||
final VoidCallback? onTap;
|
||||
|
||||
IconData _iconByStatus() {
|
||||
switch (device.status) {
|
||||
case DeviceStatus.online:
|
||||
return Icons.dns;
|
||||
case DeviceStatus.offline:
|
||||
return Icons.dns_outlined;
|
||||
case DeviceStatus.warning:
|
||||
return Icons.warning_amber;
|
||||
}
|
||||
}
|
||||
|
||||
String _title(BuildContext context) {
|
||||
// 设备标识以 MAC 为主(房号独立显示在副标题,不再用「/」粘连)。
|
||||
return device.macLabel;
|
||||
}
|
||||
|
||||
String _subtitle(BuildContext context) {
|
||||
// 副标题保持「门店 + 型号」不变,并追加独立的「房号:XXX」字段。
|
||||
final t = AppLocalizations.of(context);
|
||||
final store = device.storeName.isEmpty
|
||||
? t.deviceSubtitleStore(t.deviceSubtitleNotSet)
|
||||
: t.deviceSubtitleStore(device.storeName);
|
||||
final model = '${t.modelLabel}:${device.model.isNotEmpty ? device.model : t.deviceSubtitleNotSet}';
|
||||
final room = '${t.roomNo}:${device.roomNo.isNotEmpty ? device.roomNo : t.deviceSubtitleNotSet}';
|
||||
return '$store $model $room';
|
||||
}
|
||||
|
||||
void _showCommandSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => DeviceCommandSheet(
|
||||
device: device,
|
||||
deviceService: DeviceService(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: ListTile(
|
||||
leading: Icon(_iconByStatus(), size: 32),
|
||||
title: Text(
|
||||
_title(context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(_subtitle(context)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.touch_app_outlined),
|
||||
tooltip: AppLocalizations.of(context).sendCommand,
|
||||
onPressed: () => _showCommandSheet(context),
|
||||
),
|
||||
),
|
||||
StatusChip(status: device.status),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/widgets/metric_card.dart
Normal file
75
lib/widgets/metric_card.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 指标卡片:展示设备单项运行指标。
|
||||
class MetricCard extends StatelessWidget {
|
||||
const MetricCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.progress,
|
||||
this.accentColor,
|
||||
});
|
||||
|
||||
/// 指标图标。
|
||||
final IconData icon;
|
||||
|
||||
/// 指标名称(如:CPU 使用率)。
|
||||
final String title;
|
||||
|
||||
/// 指标值文本(如:87.0%)。
|
||||
final String value;
|
||||
|
||||
/// 进度(0.0 ~ 1.0),为 null 时不显示进度条。
|
||||
final double? progress;
|
||||
|
||||
/// 强调色,默认使用主题主色。
|
||||
final Color? accentColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = accentColor ?? Theme.of(context).colorScheme.primary;
|
||||
final progressValue = progress?.clamp(0.0, 1.0).toDouble();
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (progressValue != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: progressValue,
|
||||
color: color,
|
||||
backgroundColor: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
337
lib/widgets/org_picker_sheet.dart
Normal file
337
lib/widgets/org_picker_sheet.dart
Normal file
@@ -0,0 +1,337 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/org_node.dart';
|
||||
|
||||
/// 组织选择项:携带层级深度与是否可选,用于按组织树自上而下排序并缩进展示。
|
||||
class OrgPickerOption {
|
||||
const OrgPickerOption({
|
||||
required this.node,
|
||||
this.depth = 0,
|
||||
this.selectable = true,
|
||||
});
|
||||
|
||||
final OrgNode node;
|
||||
final int depth;
|
||||
final bool selectable;
|
||||
}
|
||||
|
||||
/// 组织选择器:以底部弹窗形式展示层级组织列表,支持关键字实时搜索,
|
||||
/// 选中后通过 Navigator.pop 返回对应的 [OrgPickerOption]。
|
||||
class OrgPickerSheet extends StatefulWidget {
|
||||
const OrgPickerSheet({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.searchHint,
|
||||
required this.emptyHint,
|
||||
required this.options,
|
||||
this.selectedId,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String searchHint;
|
||||
final String emptyHint;
|
||||
final List<OrgPickerOption> options;
|
||||
final String? selectedId;
|
||||
|
||||
@override
|
||||
State<OrgPickerSheet> createState() => _OrgPickerSheetState();
|
||||
}
|
||||
|
||||
class _OrgPickerSheetState extends State<OrgPickerSheet> {
|
||||
final _searchController = TextEditingController();
|
||||
String _keyword = '';
|
||||
String? _selectedId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedId = widget.selectedId;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSelect(OrgPickerOption option) {
|
||||
if (_selectedId == option.node.id) {
|
||||
Navigator.pop(context, option);
|
||||
return;
|
||||
}
|
||||
setState(() => _selectedId = option.node.id);
|
||||
Future.delayed(const Duration(milliseconds: 120), () {
|
||||
if (mounted) Navigator.pop(context, option);
|
||||
});
|
||||
}
|
||||
|
||||
List<OrgPickerOption> get _filteredOptions {
|
||||
final keyword = _keyword.trim().toLowerCase();
|
||||
final list = widget.options.where((option) {
|
||||
if (!option.selectable) return false;
|
||||
if (keyword.isEmpty) return true;
|
||||
return option.node.name.toLowerCase().contains(keyword);
|
||||
}).toList();
|
||||
// 将当前已选中的组织置顶展示,方便快速找到并确认当前选择。
|
||||
if (_selectedId?.isNotEmpty == true) {
|
||||
final selectedIndex = list.indexWhere((o) => o.node.id == _selectedId);
|
||||
if (selectedIndex > 0) {
|
||||
final selected = list.removeAt(selectedIndex);
|
||||
list.insert(0, selected);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final filtered = _filteredOptions;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 标题栏
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// 搜索框
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.searchHint,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _keyword.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _keyword = '');
|
||||
},
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
onChanged: (v) => setState(() => _keyword = v),
|
||||
),
|
||||
),
|
||||
// 结果列表
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
child: filtered.isEmpty
|
||||
? SizedBox(
|
||||
height: 120,
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.emptyHint,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => const Divider(
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final option = filtered[index];
|
||||
final selected = _selectedId == option.node.id;
|
||||
final baseStyle = theme.textTheme.bodyMedium;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.only(
|
||||
left: 16 + option.depth * 16.0,
|
||||
right: 16,
|
||||
),
|
||||
title: Text(
|
||||
option.node.name,
|
||||
style: option.selectable
|
||||
? baseStyle?.copyWith(
|
||||
color: selected
|
||||
? theme.colorScheme.primary
|
||||
: null,
|
||||
fontWeight: selected
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
)
|
||||
: baseStyle?.copyWith(
|
||||
color: theme.disabledColor,
|
||||
),
|
||||
),
|
||||
trailing: selected
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
color: theme.colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
selected: selected,
|
||||
selectedTileColor: theme.colorScheme.primaryContainer
|
||||
.withOpacity(0.38),
|
||||
onTap: option.selectable
|
||||
? () => _onSelect(option)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// 底部安全区
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 表单中的「组织选择」输入框:点击后弹出 [OrgPickerSheet],
|
||||
/// 进入页面时自动展开选择器并高亮已选项。
|
||||
class OrgPickerFormField extends StatefulWidget {
|
||||
const OrgPickerFormField({
|
||||
super.key,
|
||||
this.value,
|
||||
required this.labelText,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
this.validator,
|
||||
this.autoOpen = true,
|
||||
this.searchHint,
|
||||
this.emptyHint,
|
||||
});
|
||||
|
||||
final String? value;
|
||||
final String labelText;
|
||||
final List<OrgPickerOption> options;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final String? Function(String?)? validator;
|
||||
final bool autoOpen;
|
||||
final String? searchHint;
|
||||
final String? emptyHint;
|
||||
|
||||
@override
|
||||
State<OrgPickerFormField> createState() => _OrgPickerFormFieldState();
|
||||
}
|
||||
|
||||
class _OrgPickerFormFieldState extends State<OrgPickerFormField> {
|
||||
bool _autoOpened = false;
|
||||
FormFieldState<String>? _field;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scheduleAutoOpen();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant OrgPickerFormField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 外部 value 变更时同步到 FormField 状态。
|
||||
if (oldWidget.value != widget.value) {
|
||||
_field?.didChange(widget.value);
|
||||
}
|
||||
// 选项从空变为有值时(异步加载完成),立即自动展开选择器。
|
||||
if (oldWidget.options.isEmpty && widget.options.isNotEmpty) {
|
||||
_scheduleAutoOpen();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleAutoOpen() {
|
||||
if (!_shouldAutoOpen) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _shouldAutoOpen) {
|
||||
_autoOpened = true;
|
||||
_openPicker();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool get _shouldAutoOpen =>
|
||||
widget.autoOpen && widget.options.isNotEmpty && !_autoOpened;
|
||||
|
||||
Future<void> _openPicker() async {
|
||||
if (!mounted) return;
|
||||
final selected = await showModalBottomSheet<OrgPickerOption>(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => OrgPickerSheet(
|
||||
title: widget.labelText,
|
||||
searchHint: widget.searchHint ?? '搜索组织名称',
|
||||
emptyHint: widget.emptyHint ?? '未找到匹配组织',
|
||||
options: widget.options,
|
||||
selectedId: widget.value,
|
||||
),
|
||||
);
|
||||
if (selected != null && mounted) {
|
||||
_field?.didChange(selected.node.id);
|
||||
widget.onChanged(selected.node.id);
|
||||
}
|
||||
}
|
||||
|
||||
String? _displayName(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
for (final option in widget.options) {
|
||||
if (option.node.id == value) return option.node.name;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormField<String>(
|
||||
initialValue: widget.value,
|
||||
validator: widget.validator,
|
||||
builder: (field) {
|
||||
_field = field;
|
||||
final displayName = _displayName(field.value);
|
||||
final theme = Theme.of(context);
|
||||
return InkWell(
|
||||
onTap: _openPicker,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.labelText,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.account_tree),
|
||||
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||
errorText: field.errorText,
|
||||
),
|
||||
child: Text(
|
||||
displayName ?? widget.labelText,
|
||||
style: TextStyle(
|
||||
color: displayName == null ? theme.hintColor : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
334
lib/widgets/org_tree_filter.dart
Normal file
334
lib/widgets/org_tree_filter.dart
Normal file
@@ -0,0 +1,334 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/org_node.dart';
|
||||
import '../services/org_service.dart';
|
||||
|
||||
/// 打开一个底部抽屉式的「组织树筛选」面板。
|
||||
///
|
||||
/// - [initialSelected]:初始已选中的组织节点 ID 集合(空集表示未筛选)。
|
||||
/// - [allowMultiple]:是否允许多选(默认 true,与 Web 端 check-strictly 非级联多选一致)。
|
||||
///
|
||||
/// 返回值为用户「确定」时选中的节点 ID 集合;点击「取消」或空白处关闭返回 null
|
||||
/// (调用方应据此保留原有筛选状态不变)。
|
||||
Future<Set<String>?> showOrgTreeFilterSheet(
|
||||
BuildContext context, {
|
||||
required Set<String> initialSelected,
|
||||
bool allowMultiple = true,
|
||||
}) {
|
||||
return showModalBottomSheet<Set<String>?>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (ctx) => _OrgTreeFilterSheet(
|
||||
initialSelected: initialSelected,
|
||||
allowMultiple: allowMultiple,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 组织树筛选抽屉的内部状态组件。
|
||||
class _OrgTreeFilterSheet extends StatefulWidget {
|
||||
const _OrgTreeFilterSheet({
|
||||
required this.initialSelected,
|
||||
required this.allowMultiple,
|
||||
});
|
||||
|
||||
final Set<String> initialSelected;
|
||||
final bool allowMultiple;
|
||||
|
||||
@override
|
||||
State<_OrgTreeFilterSheet> createState() => _OrgTreeFilterSheetState();
|
||||
}
|
||||
|
||||
class _OrgTreeFilterSheetState extends State<_OrgTreeFilterSheet> {
|
||||
List<OrgNode> _tree = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
/// 已选中的节点 ID 集合(命中状态与抽屉内一致,确定时回传)。
|
||||
final Set<String> _selected = {};
|
||||
|
||||
/// 展开/折叠状态:集合内的节点视为「展开」。
|
||||
final Set<String> _expanded = {};
|
||||
|
||||
/// 顶部搜索关键词(非空时进入「搜索定位」模式)。
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected.addAll(widget.initialSelected);
|
||||
_loadTree();
|
||||
}
|
||||
|
||||
Future<void> _loadTree() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final tree = await OrgService().getOrgTree();
|
||||
if (!mounted) return;
|
||||
// 默认展开顶层节点,便于一眼看到其下组织。
|
||||
final topIds = tree.map((n) => n.id).toSet();
|
||||
setState(() {
|
||||
_tree = tree;
|
||||
_loading = false;
|
||||
_expanded.addAll(topIds);
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换节点选中状态(非级联:仅影响当前节点本身)。
|
||||
void _toggleSelect(OrgNode node) {
|
||||
setState(() {
|
||||
if (_selected.contains(node.id)) {
|
||||
_selected.remove(node.id);
|
||||
} else {
|
||||
if (widget.allowMultiple) {
|
||||
_selected.add(node.id);
|
||||
} else {
|
||||
// 单选:先清空再选中当前节点。
|
||||
_selected.clear();
|
||||
_selected.add(node.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 切换节点展开/折叠(无子节点时无效)。
|
||||
void _toggleExpand(OrgNode node) {
|
||||
if (node.children.isEmpty) return;
|
||||
setState(() {
|
||||
if (_expanded.contains(node.id)) {
|
||||
_expanded.remove(node.id);
|
||||
} else {
|
||||
_expanded.add(node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 计算当前应展示的扁平行(含层级 depth)。
|
||||
///
|
||||
/// - 无搜索关键词:按 [_expanded] 折叠/展开展示完整树;
|
||||
/// - 有搜索关键词:递归保留命中节点及其祖先路径(忽略展开态),便于「搜索定位节点」。
|
||||
List<_OrgRow> _computeRows() {
|
||||
final rows = <_OrgRow>[];
|
||||
final q = _query.trim().toLowerCase();
|
||||
if (q.isNotEmpty) {
|
||||
// 命中子树:仅保留自身或后代匹配的节点(保留祖先链)。
|
||||
final matched = _filterTree(_tree, q);
|
||||
void walk(List<OrgNode> ns, int depth) {
|
||||
for (final n in ns) {
|
||||
rows.add(_OrgRow(node: n, depth: depth));
|
||||
if (n.children.isNotEmpty) walk(n.children, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
walk(matched, 0);
|
||||
return rows;
|
||||
}
|
||||
void walk(List<OrgNode> ns, int depth) {
|
||||
for (final n in ns) {
|
||||
rows.add(_OrgRow(node: n, depth: depth));
|
||||
if (n.children.isNotEmpty && _expanded.contains(n.id)) {
|
||||
walk(n.children, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(_tree, 0);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// 递归过滤组织树:保留自身匹配或后代匹配的节点,并裁剪无关节点。
|
||||
static List<OrgNode> _filterTree(List<OrgNode> nodes, String query) {
|
||||
final result = <OrgNode>[];
|
||||
for (final n in nodes) {
|
||||
final children = _filterTree(n.children, query);
|
||||
final selfMatch = n.name.toLowerCase().contains(query);
|
||||
if (selfMatch || children.isNotEmpty) {
|
||||
result.add(n.copyWith(children: children));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rows = _computeRows();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.85,
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部标题栏
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'组织筛选',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// 搜索框
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索组织名称',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
isDense: true,
|
||||
suffixIcon: _query.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () => setState(() => _query = ''),
|
||||
),
|
||||
),
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
),
|
||||
),
|
||||
// 树体
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('组织树加载失败:$_error'),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: _loadTree,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: rows.isEmpty
|
||||
? const Center(child: Text('无匹配组织'))
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1),
|
||||
itemBuilder: (ctx, i) {
|
||||
final row = rows[i];
|
||||
final node = row.node;
|
||||
final hasChildren = node.children.isNotEmpty;
|
||||
final isExpanded = _expanded.contains(node.id);
|
||||
return Padding(
|
||||
// 按层级深度缩进(参考 channel_management_page 范式)。
|
||||
padding: EdgeInsets.only(
|
||||
left: 12.0 + row.depth * 20,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (hasChildren)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _toggleExpand(node),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 40),
|
||||
Checkbox(
|
||||
value: _selected.contains(node.id),
|
||||
onChanged: (_) => _toggleSelect(node),
|
||||
),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _toggleSelect(node),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10,
|
||||
horizontal: 4,
|
||||
),
|
||||
child: Text(
|
||||
node.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// 底部操作栏
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
8,
|
||||
16,
|
||||
MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'已选 ${_selected.length} 项',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _selected.clear()),
|
||||
child: const Text('清空'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, Set<String>.from(_selected)),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 组织树的一行(节点 + 层级深度)。
|
||||
class _OrgRow {
|
||||
const _OrgRow({required this.node, required this.depth});
|
||||
|
||||
final OrgNode node;
|
||||
final int depth;
|
||||
}
|
||||
208
lib/widgets/region_picker.dart
Normal file
208
lib/widgets/region_picker.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/region_data.dart';
|
||||
|
||||
/// 省→市→区三级联动结果。
|
||||
class RegionResult {
|
||||
const RegionResult({
|
||||
required this.provinceCode,
|
||||
required this.provinceName,
|
||||
required this.cityCode,
|
||||
required this.cityName,
|
||||
required this.districtCode,
|
||||
required this.districtName,
|
||||
});
|
||||
|
||||
final String provinceCode;
|
||||
final String provinceName;
|
||||
final String cityCode;
|
||||
final String cityName;
|
||||
final String districtCode;
|
||||
final String districtName;
|
||||
|
||||
/// 拼接展示文本(省名+市名+区名)。
|
||||
String get label =>
|
||||
'$provinceName$cityName$districtName';
|
||||
}
|
||||
|
||||
/// 省→市→区三级联动底部弹起选择器。
|
||||
///
|
||||
/// 数据源统一来自 [RegionData](已加载的 `province.json`)。
|
||||
/// 返回 [RegionResult](含 code 与 name);用户取消时弹出 null。
|
||||
class RegionPicker extends StatefulWidget {
|
||||
const RegionPicker({
|
||||
super.key,
|
||||
this.initialProvinceCode,
|
||||
this.initialCityCode,
|
||||
this.initialDistrictCode,
|
||||
});
|
||||
|
||||
final String? initialProvinceCode;
|
||||
final String? initialCityCode;
|
||||
final String? initialDistrictCode;
|
||||
|
||||
/// 打开选择器底部弹窗,返回所选 [RegionResult] 或 null。
|
||||
static Future<RegionResult?> show({
|
||||
required BuildContext context,
|
||||
String? initialProvinceCode,
|
||||
String? initialCityCode,
|
||||
String? initialDistrictCode,
|
||||
}) {
|
||||
return showModalBottomSheet<RegionResult>(
|
||||
useSafeArea: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => RegionPicker(
|
||||
initialProvinceCode: initialProvinceCode,
|
||||
initialCityCode: initialCityCode,
|
||||
initialDistrictCode: initialDistrictCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<RegionPicker> createState() => _RegionPickerState();
|
||||
}
|
||||
|
||||
class _RegionPickerState extends State<RegionPicker> {
|
||||
final RegionData _region = RegionData.instance;
|
||||
|
||||
/// 当前选中层级:0=省 1=市 2=区。
|
||||
int _level = 0;
|
||||
String? _provinceCode;
|
||||
String? _cityCode;
|
||||
String? _districtCode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_provinceCode = widget.initialProvinceCode;
|
||||
_cityCode = widget.initialCityCode;
|
||||
_districtCode = widget.initialDistrictCode;
|
||||
// 若已带初始省,直接进入市层级以便快速定位。
|
||||
if (_provinceCode != null && _provinceCode!.isNotEmpty) {
|
||||
_level = 1;
|
||||
if (_cityCode != null && _cityCode!.isNotEmpty) {
|
||||
_level = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<RegionNode> get _currentList {
|
||||
switch (_level) {
|
||||
case 0:
|
||||
return _region.provinces;
|
||||
case 1:
|
||||
return _region.citiesOf(_provinceCode ?? '');
|
||||
case 2:
|
||||
return _region.districtsOf(
|
||||
_provinceCode ?? '',
|
||||
_cityCode ?? '',
|
||||
);
|
||||
default:
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
String _levelTitle() {
|
||||
switch (_level) {
|
||||
case 0:
|
||||
return '选择省份';
|
||||
case 1:
|
||||
return '选择城市';
|
||||
case 2:
|
||||
return '选择区县';
|
||||
default:
|
||||
return '选择地区';
|
||||
}
|
||||
}
|
||||
|
||||
void _onSelect(RegionNode node) {
|
||||
if (_level == 0) {
|
||||
setState(() {
|
||||
_provinceCode = node.code;
|
||||
_cityCode = null;
|
||||
_districtCode = null;
|
||||
_level = 1;
|
||||
});
|
||||
} else if (_level == 1) {
|
||||
setState(() {
|
||||
_cityCode = node.code;
|
||||
_districtCode = null;
|
||||
_level = 2;
|
||||
});
|
||||
} else {
|
||||
final provinceName = _region.nameOf(_provinceCode ?? '');
|
||||
final cityName = _region.nameOf(_cityCode ?? '');
|
||||
Navigator.pop(
|
||||
context,
|
||||
RegionResult(
|
||||
provinceCode: _provinceCode ?? '',
|
||||
provinceName: provinceName,
|
||||
cityCode: _cityCode ?? '',
|
||||
cityName: cityName,
|
||||
districtCode: node.code,
|
||||
districtName: node.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final nodes = _currentList;
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_levelTitle(),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
if (_level > 0)
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _level--),
|
||||
child: const Text('上一级'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
itemCount: nodes.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final node = nodes[index];
|
||||
final isSelected = _isSelected(node);
|
||||
return ListTile(
|
||||
title: Text(node.name),
|
||||
trailing: isSelected
|
||||
? Icon(Icons.check,
|
||||
color: Theme.of(context).colorScheme.primary)
|
||||
: const Icon(Icons.chevron_right),
|
||||
onTap: () => _onSelect(node),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isSelected(RegionNode node) {
|
||||
if (_level == 0) return node.code == _provinceCode;
|
||||
if (_level == 1) return node.code == _cityCode;
|
||||
return node.code == _districtCode;
|
||||
}
|
||||
}
|
||||
57
lib/widgets/status_chip.dart
Normal file
57
lib/widgets/status_chip.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/device.dart';
|
||||
import '../utils/i10n.dart';
|
||||
|
||||
/// 设备状态标签。
|
||||
class StatusChip extends StatelessWidget {
|
||||
const StatusChip({super.key, required this.status});
|
||||
|
||||
/// 设备状态。
|
||||
final DeviceStatus status;
|
||||
|
||||
Color _color(BuildContext context) {
|
||||
switch (status) {
|
||||
case DeviceStatus.online:
|
||||
return Colors.green;
|
||||
case DeviceStatus.offline:
|
||||
return Colors.grey;
|
||||
case DeviceStatus.warning:
|
||||
return Colors.orange;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = _color(context);
|
||||
final t = AppLocalizations.of(context);
|
||||
final label = switch (status) {
|
||||
DeviceStatus.online => t.statusOnline,
|
||||
DeviceStatus.offline => t.statusOffline,
|
||||
DeviceStatus.warning => t.statusWarning,
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withOpacity(0.6)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle, size: 8, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/widgets/store_theme_grid.dart
Normal file
146
lib/widgets/store_theme_grid.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/theme_template.dart';
|
||||
|
||||
/// 主题模板网格卡片(封面图 + 名称)。
|
||||
///
|
||||
/// 用于门店表单中「主题皮肤」选择;[selectedId] 高亮选中态,
|
||||
/// [onSelected] 回传选中模板的 [ThemeTemplate.id]。
|
||||
class StoreThemeGrid extends StatelessWidget {
|
||||
const StoreThemeGrid({
|
||||
super.key,
|
||||
required this.templates,
|
||||
required this.onSelected,
|
||||
this.selectedId,
|
||||
});
|
||||
|
||||
/// 可选主题模板列表。
|
||||
final List<ThemeTemplate> templates;
|
||||
|
||||
/// 选中回调,回传模板 id。
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
/// 当前选中的模板 id(高亮)。
|
||||
final String? selectedId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (templates.isEmpty) {
|
||||
return const SizedBox(
|
||||
height: 80,
|
||||
child: Center(child: Text('暂无可用主题模板')),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.count(
|
||||
crossAxisCount: 3,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.9,
|
||||
children: templates.map((template) {
|
||||
final selected = template.id == selectedId;
|
||||
return _ThemeCard(
|
||||
template: template,
|
||||
selected: selected,
|
||||
onTap: () => onSelected(template.id),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeCard extends StatelessWidget {
|
||||
const _ThemeCard({
|
||||
required this.template,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final ThemeTemplate template;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = _accentColor(template.name);
|
||||
final coverUrl = template.coverUrl;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
width: selected ? 2.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 封面预览(优先 coverUrl,否则用名称哈希色块)。
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: coverUrl == null || coverUrl.isEmpty ? primary : null,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(10)),
|
||||
image: coverUrl != null && coverUrl.isNotEmpty
|
||||
? DecorationImage(
|
||||
image: NetworkImage(coverUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: coverUrl == null || coverUrl.isEmpty
|
||||
? Text(
|
||||
template.name.isEmpty ? '主题' : template.name[0],
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
// 名称。
|
||||
Container(
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
template.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _palette = [
|
||||
Color(0xFF3B82F6),
|
||||
Color(0xFF8B5CF6),
|
||||
Color(0xFFEC4899),
|
||||
Color(0xFFF59E0B),
|
||||
Color(0xFF10B981),
|
||||
Color(0xFF6366F1),
|
||||
];
|
||||
|
||||
static Color _accentColor(String name) {
|
||||
var hash = 0;
|
||||
for (var i = 0; i < name.length; i++) {
|
||||
hash = (hash + name.codeUnitAt(i) * (i + 1)) % _palette.length;
|
||||
}
|
||||
return _palette[hash];
|
||||
}
|
||||
}
|
||||
177
lib/widgets/store_theme_group_list.dart
Normal file
177
lib/widgets/store_theme_group_list.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/theme_group.dart';
|
||||
|
||||
/// 门店界面配置:按设备分组展示已添加的主题。
|
||||
///
|
||||
/// 每组横向滚动显示主题卡片,激活主题带高亮边框;
|
||||
/// 点击主题卡片触发 [onThemeTap]。
|
||||
class StoreThemeGroupList extends StatelessWidget {
|
||||
const StoreThemeGroupList({
|
||||
super.key,
|
||||
required this.groups,
|
||||
this.onThemeTap,
|
||||
this.emptyHint,
|
||||
});
|
||||
|
||||
/// 分组及主题数据。
|
||||
final List<ThemeGroup> groups;
|
||||
|
||||
/// 主题点击回调;参数为 (groupId, theme)。
|
||||
final void Function(int groupId, ThemePage theme)? onThemeTap;
|
||||
|
||||
/// 空数据提示文案。
|
||||
final String? emptyHint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (groups.isEmpty) {
|
||||
return SizedBox(
|
||||
height: 80,
|
||||
child: Center(
|
||||
child: Text(
|
||||
emptyHint ?? '暂无界面配置数据',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: groups.map((group) => _buildGroup(context, group)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroup(BuildContext context, ThemeGroup group) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
group.groupName,
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${group.themes.length}/${group.themeLimit}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (group.activeThemeName != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 6),
|
||||
child: Text(
|
||||
'当前:${group.activeThemeName}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 132,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: group.themes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final themePage = group.themes[index];
|
||||
return _ThemeCard(
|
||||
themePage: themePage,
|
||||
onTap: onThemeTap != null
|
||||
? () => onThemeTap!(group.groupId, themePage)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeCard extends StatelessWidget {
|
||||
const _ThemeCard({
|
||||
required this.themePage,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final ThemePage themePage;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final photoUrl = themePage.themePhoto;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: themePage.active ? primary : Colors.transparent,
|
||||
width: themePage.active ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(9)),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
color: photoUrl == null || photoUrl.isEmpty
|
||||
? primary.withOpacity(0.2)
|
||||
: null,
|
||||
child: photoUrl != null && photoUrl.isNotEmpty
|
||||
? Image.network(
|
||||
photoUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _placeholder(primary),
|
||||
)
|
||||
: _placeholder(primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 34,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text(
|
||||
themePage.name.isEmpty ? '未命名主题' : themePage.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder(Color primary) {
|
||||
return Center(
|
||||
child: Icon(Icons.image_outlined, color: primary, size: 28),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lib/widgets/store_theme_preview.dart
Normal file
125
lib/widgets/store_theme_preview.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/theme_template.dart';
|
||||
|
||||
/// 模拟门店主页头部预览浮层。
|
||||
///
|
||||
/// 依据选中的 [ThemeTemplate] 渲染一个模拟的门店主页头部
|
||||
///(主色背景 + 门店名 + Logo 占位),用于主题选择时实时预览效果。
|
||||
class StoreThemePreview extends StatelessWidget {
|
||||
const StoreThemePreview({
|
||||
super.key,
|
||||
this.template,
|
||||
this.storeName = '示例门店',
|
||||
});
|
||||
|
||||
/// 选中的主题模板;空时回退默认主题。
|
||||
final ThemeTemplate? template;
|
||||
|
||||
/// 预览中的门店名称。
|
||||
final String storeName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = _accentColor(template?.name ?? '');
|
||||
final label = template?.name ?? '默认主题';
|
||||
final onColor = Colors.white;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [primary, primary.withOpacity(0.75)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_placeholderLogo(primary, onColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
storeName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.25),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 模拟内容卡占位。
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.18),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholderLogo(Color primary, Color onColor) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: primary, width: 2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.storefront, color: primary, size: 26),
|
||||
);
|
||||
}
|
||||
|
||||
static const _palette = [
|
||||
Color(0xFF3B82F6),
|
||||
Color(0xFF8B5CF6),
|
||||
Color(0xFFEC4899),
|
||||
Color(0xFFF59E0B),
|
||||
Color(0xFF10B981),
|
||||
Color(0xFF6366F1),
|
||||
];
|
||||
|
||||
static Color _accentColor(String name) {
|
||||
var hash = 0;
|
||||
for (var i = 0; i < name.length; i++) {
|
||||
hash = (hash + name.codeUnitAt(i) * (i + 1)) % _palette.length;
|
||||
}
|
||||
return _palette[hash];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user