ops-console v1.0.2 (versionCode 8) 源码入库

This commit is contained in:
txh
2026-09-03 18:01:05 +08:00
commit 685fff27c8
170 changed files with 53049 additions and 0 deletions

View 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,
),
],
),
);
}
}

View 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),
),
],
),
);
}

View 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;
}

View 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(),
),
),
);
}
}

View 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),
),
],
),
);
}
}

View 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,
),
);
}
}

View 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),
),
],
],
),
),
);
}
}

View 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,
),
),
),
);
},
);
}
}

View 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;
}

View 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;
}
}

View 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,
),
),
],
),
);
}
}

View 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];
}
}

View 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),
);
}
}

View 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];
}
}