// screens/devices/device_management_screen.dart — 设备管理(照原型 devices.html 重建)。 // 三区块:登录设备管理(会话表,后端 /sessions) + 外设设备(卡片网格,店级 // custom_fields.peripherals 本地存档) + 打印模板(静态两卡)。无 KPI/toolbar/分页。 // 外设为登记式存档(用户口径):测试打印/配置为原型态占位,解绑=从清单删除。 // 窄屏(原型 m-devices.html):无页头,m-section 两区块(会话卡流 + 外设卡流, // 「+ 添加设备」在区块标题行右侧,不用 FAB);详情/添加走底部 sheet; // 打印模板窄屏不渲染(拍板:移动端无打印)。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/auth/auth_state.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/utils/dialog_util.dart'; import '../../models/session.dart'; import '../../providers/session_provider.dart'; import '../../providers/shop_provider.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_table.dart'; import '../../widgets/ds/m_sheet.dart'; import '../../widgets/ds/status_icon_map.dart'; import '../../widgets/write_guard.dart'; import '../../core/theme/app_fonts.dart'; import '../../widgets/ds/ds_toast.dart'; /// 店级 custom_fields.peripherals 的一项(登记式外设存档)。 class Peripheral { final String name; final String kind; // 标签打印机/小票打印机/扫码枪/电子秤 final String model; // 型号 / 地址 final String conn; // USB/蓝牙/WiFi/网络 final String status; // 在线/离线 final String last; // 最近活动(存档文案) const Peripheral({ required this.name, required this.kind, this.model = '', this.conn = 'USB', this.status = '在线', this.last = '刚刚', }); Map toMap() => { 'name': name, 'kind': kind, 'model': model, 'conn': conn, 'status': status, 'last': last, }; factory Peripheral.fromMap(Map m) => Peripheral( name: m['name'] as String? ?? '', kind: m['kind'] as String? ?? '外设', model: m['model'] as String? ?? '', conn: m['conn'] as String? ?? '', status: m['status'] as String? ?? '在线', last: m['last'] as String? ?? '', ); /// 外设类型 → lucide 图标(原型 dev-ic 内联 SVG 的对应物) IconData get icon => switch (kind) { '标签打印机' || '小票打印机' => LucideIcons.printer, '扫码枪' => LucideIcons.scanBarcode, '电子秤' => LucideIcons.scale, _ => LucideIcons.cable, }; } /// 从店信息读外设清单(custom_fields.peripherals)。 List peripheralsOf(Map customFields) => [ for (final m in (customFields['peripherals'] as List? ?? const [])) if (m is Map) Peripheral.fromMap(Map.from(m)), ]; class DeviceManagementScreen extends ConsumerStatefulWidget { const DeviceManagementScreen({super.key}); @override ConsumerState createState() => _DeviceManagementScreenState(); } class _DeviceManagementScreenState extends ConsumerState { static final _fmt = DateFormat('MM-dd HH:mm'); static const _kinds = ['标签打印机', '小票打印机', '扫码枪', '电子秤']; static const _conns = ['USB', '蓝牙', 'WiFi', '网络']; void _reload() { ref.read(sessionListProvider.notifier).reload(); ref.invalidate(shopInfoProvider); } void _snack(String msg, {bool err = false}) { showDsToast(context, msg, bg: err ? context.tokens.danger : context.tokens.success); } @override Widget build(BuildContext context) { final t = context.tokens; final peripherals = peripheralsOf( ref.watch(shopInfoProvider).valueOrNull?.customFields ?? const {}); final on = peripherals.where((p) => p.status == '在线').length; // ── 窄屏(原型 m-devices.html):会话卡流 + 外设卡流,无页头/打印模板 ── if (context.isMobile) return _mobileBody(t, peripherals); return Container( color: t.bg, child: SingleChildScrollView( // 原型 .main{padding:22px 26px} padding: const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── 头部(原型 .head{margin-bottom:18px})── Padding( padding: const EdgeInsets.only(bottom: 18), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('设备管理', style: TextStyle( fontSize: AppDims.fsH1, fontWeight: FontWeight.w700, color: t.heading)), const SizedBox(width: AppDims.sp3), Padding( padding: const EdgeInsets.only(bottom: 2), child: Text('外设连接与打印配置 · 在线 $on/${peripherals.length}', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ), const Spacer(), DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: () { _reload(); _snack('设备状态已刷新 ✓'); }), const SizedBox(width: 10), WriteGuard( child: DsButton('添加设备', icon: LucideIcons.plus, variant: DsBtnVariant.primary, onPressed: _openAdd), ), ], ), ), // ── 区块 A:登录设备管理 ── _secTitle(t, '登录设备管理', '管理员可强制下线其他登录设备', first: true), _sessionSection(), // ── 区块 B:外设设备 ── _secTitle(t, '外设设备', '共 ${peripherals.length} 台 · 在线 $on'), _devGrid(peripherals), // ── 区块 C:打印模板 ── _secTitle(t, '打印模板', '标签与小票排版'), _tplGrid(t), ], ), ), ); } // ── 窄屏整体(原型 m-devices.html)────────────────────────── Widget _mobileBody(dynamic t, List peripherals) { final canKick = _canKick; final async = ref.watch(sessionListProvider); return Container( color: t.bg, child: ListView( padding: const EdgeInsets.all(14), children: [ _mSection(t, '登录设备管理'), async.when( loading: () => const Padding( padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator())), error: (e, _) => Row(children: [ _emptyHint(t, '会话加载失败'), const SizedBox(width: 10), DsButton('重试', small: true, onPressed: () => ref.read(sessionListProvider.notifier).reload()), ]), data: (sessions) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (sessions.isEmpty) _emptyHint(t, '暂无登录设备'), for (final s in sessions) _mSessionCard(t, s, canKick), ], ), ), // 区块标题行右侧「+ 添加设备」文字入口(原型形态,不用 FAB) _mSection(t, '外设设备', trailing: WriteGuard( child: InkWell( onTap: _openAddSheet, child: Text('+ 添加设备', style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, color: t.primary)), ), )), if (peripherals.isEmpty) _emptyHint(t, '还没有外设 · 点「添加设备」登记'), for (var i = 0; i < peripherals.length; i++) _mPeripheralCard(t, peripherals, i), ], ), ); } bool get _canKick { final role = ref.watch(authStateProvider.select((s) => s.user?.role)); return role == 'admin' || role == 'superadmin'; } /// .m-section(可带右侧动作,如「+ 添加设备」)。 Widget _mSection(dynamic t, String label, {Widget? trailing}) => Padding( padding: const EdgeInsets.fromLTRB(2, 8, 2, 8), child: Row(children: [ Expanded( child: Text(label, style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w700, letterSpacing: .4, color: t.muted)), ), if (trailing != null) trailing, ]), ); /// .m-card 外壳(surface / border / r-lg / pad 13 14 / mb10)。 Widget _mCard(dynamic t, {required Widget child, VoidCallback? onTap}) => Padding( padding: const EdgeInsets.only(bottom: 10), child: Material( color: t.surface, clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder( side: BorderSide(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), child: child, ), ), ), ); /// 状态徽章(图标变体:在线=wifi / 离线=wifi-off)。 DsBadge _statusBadgeIc(String status) => DsBadge(status, tone: status == '在线' ? DsBadgeTone.ok : DsBadgeTone.muted, icon: statusIcon(status)); /// 会话卡(原型:用户·平台+本机 / 设备·IP / 在线徽章+时间 / ›)。 Widget _mSessionCard(dynamic t, DeviceSession s, bool canKick) { String fmt(DateTime? d) => d == null ? '—' : _fmt.format(d); return _mCard( t, onTap: () => _openSessionSheet(s, canKick), child: Row(children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Flexible( child: Text('${_sessionName(s)} · ${s.platformLabel}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.heading)), ), if (s.isCurrent) ...[ const SizedBox(width: 6), Text('本机', style: TextStyle( fontSize: AppDims.fsXs, fontWeight: FontWeight.w600, color: t.primary)), ], ]), const SizedBox(height: 3), Text( '${s.deviceName.isEmpty ? s.platform : s.deviceName}' '${s.ip.isEmpty ? '' : ' · ${s.ip}'}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppDims.fsXs, color: t.muted, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), ], ), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ _statusBadgeIc(s.online ? '在线' : '离线'), const SizedBox(height: 5), Text(fmt(s.lastSeenAt), style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), ], ), const SizedBox(width: 4), Icon(LucideIcons.chevronRight, size: 18, color: t.faint), ]), ); } /// 会话详情 sheet(drow 键值 + 非本机「强制下线」danger)。 void _openSessionSheet(DeviceSession s, bool canKick) { String fmt(DateTime? d) => d == null ? '—' : _fmt.format(d); showMSheet( context, title: '${_sessionName(s)} · ${s.platformLabel}', builder: (ctx) { final t = ctx.tokens; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _drow(t, '用户', Text(_sessionName(s), style: _drowValStyle(t))), _drow( t, '设备', Text(s.deviceName.isEmpty ? s.platform : s.deviceName, style: _drowValStyle(t, mono: true))), _drow( t, 'IP 地址', Text(s.ip.isEmpty ? '—' : s.ip, style: _drowValStyle(t, mono: true))), _drow(t, '登录时间', Text(fmt(s.createdAt), style: _drowValStyle(t, mono: true))), _drow(t, '最近活跃', Text(fmt(s.lastSeenAt), style: _drowValStyle(t, mono: true))), _drow( t, '状态', Row(mainAxisSize: MainAxisSize.min, children: [ _statusBadgeIc(s.online ? '在线' : '离线'), if (s.isCurrent) ...[ const SizedBox(width: 6), Text('本机会话', style: TextStyle( fontSize: AppDims.fsXs, fontWeight: FontWeight.w600, color: t.primary)), ], ]), last: true), const SizedBox(height: 16), Row(children: [ Expanded( child: DsButton('关闭', onPressed: () => Navigator.of(ctx).pop()), ), if (canKick && !s.isCurrent) ...[ const SizedBox(width: 10), Expanded( child: DsButton('强制下线', variant: DsBtnVariant.danger, onPressed: () { Navigator.of(ctx).pop(); _confirmKick(s); }), ), ], ]), ], ); }, ); } /// 外设卡(原型:名称 / 型号·类型·连接 / 状态徽章 / mc-foot 最近活动)。 Widget _mPeripheralCard(dynamic t, List items, int index) { final d = items[index]; return _mCard( t, onTap: () => _openPeripheralSheet(items, index), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row(children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(d.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.heading)), const SizedBox(height: 3), Text( '${d.model.isEmpty ? d.kind : d.model} · ${d.kind} · ${d.conn}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), ], ), ), const SizedBox(width: 8), _statusBadgeIc(d.status), const SizedBox(width: 4), Icon(LucideIcons.chevronRight, size: 18, color: t.faint), ]), // .mc-foot:最近活动脚注 Container( margin: const EdgeInsets.only(top: 10), padding: const EdgeInsets.only(top: 10), decoration: BoxDecoration( border: Border(top: BorderSide(color: t.borderSubtle))), child: Row(children: [ Icon(LucideIcons.info, size: 13, color: t.muted), const SizedBox(width: 6), Text('最近活动 ${d.last.isEmpty ? '—' : d.last}', style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), ]), ), ], ), ); } /// 外设详情 sheet(drow 键值 + 测试/配置/解绑)。 void _openPeripheralSheet(List items, int index) { final d = items[index]; final online = d.status == '在线'; showMSheet( context, title: d.name, builder: (ctx) { final t = ctx.tokens; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _drow(t, '型号', Text(d.model.isEmpty ? '—' : d.model, style: _drowValStyle(t))), _drow(t, '类型', Text(d.kind, style: _drowValStyle(t))), _drow(t, '连接方式', Text(d.conn, style: _drowValStyle(t, mono: true))), _drow(t, '最近活动', Text(d.last.isEmpty ? '—' : d.last, style: _drowValStyle(t))), _drow(t, '状态', _statusBadgeIc(d.status), last: true), const SizedBox(height: 16), Row(children: [ Expanded( child: DsButton('测试', onPressed: () => online ? _snack('已发送测试打印 → ${d.name}') : _snack('设备离线,无法测试 · ${d.name}', err: true)), ), const SizedBox(width: 10), Expanded( child: DsButton('配置', onPressed: () => _snack('外设配置即将上线')), ), const SizedBox(width: 10), Expanded( child: WriteGuard( child: DsButton('解绑', variant: DsBtnVariant.danger, onPressed: () { Navigator.of(ctx).pop(); _unbind(items, index); }), ), ), ]), ], ); }, ); } /// atoms .drow:label muted + 右值 w600,行距 11、subtle 分隔。 Widget _drow(dynamic t, String label, Widget value, {bool last = false}) => Container( padding: const EdgeInsets.symmetric(vertical: 11), decoration: BoxDecoration( border: last ? null : Border(bottom: BorderSide(color: t.borderSubtle)), ), child: Row(children: [ Text(label, style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), const Spacer(), value, ]), ); TextStyle _drowValStyle(dynamic t, {bool mono = false}) => TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.text, fontFamily: mono ? AppFonts.mono : null, fontFamilyFallback: mono ? AppFonts.monoFallback : null, ); /// 添加设备 sheet(原型 drawAdd:m-pill 选类型/连接 + 名称/型号 + 保存)。 void _openAddSheet() { final nameCtrl = TextEditingController(); final modelCtrl = TextEditingController(); var kind = _kinds.first; var conn = _conns.first; showMSheet( context, title: '添加设备', builder: (ctx) => StatefulBuilder(builder: (ctx, setLocal) { final t = ctx.tokens; Widget pills(String label, List opts, String sel, ValueChanged onSel) => Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), const SizedBox(height: 6), Wrap(spacing: 8, runSpacing: 8, children: [ for (final v in opts) // 原型 .m-pill:h32 / r-pill / on=brand50+primary InkWell( onTap: () => setLocal(() => onSel(v)), borderRadius: BorderRadius.circular(AppDims.rPill), child: Container( height: 32, padding: const EdgeInsets.symmetric(horizontal: 13), alignment: Alignment.center, decoration: BoxDecoration( color: v == sel ? t.brand50 : t.surface, border: Border.all( color: v == sel ? t.primary : t.border), borderRadius: BorderRadius.circular(AppDims.rPill), ), child: Text(v, style: TextStyle( fontSize: AppDims.fsSm, fontWeight: v == sel ? FontWeight.w600 : FontWeight.w400, color: v == sel ? t.primary : t.text)), ), ), ]), ], ), ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ pills('设备类型', _kinds, kind, (v) => kind = v), pills('连接方式', _conns, conn, (v) => conn = v), Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('设备名称', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), const SizedBox(height: 6), DsInput(controller: nameCtrl, hintText: '如:前台标签机'), ], ), ), Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('型号 / 地址', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), const SizedBox(height: 6), DsInput( controller: modelCtrl, hintText: '如:Zebra GK888t 或 192.168.1.50'), ], ), ), Row(children: [ Expanded( child: DsButton('取消', onPressed: () => Navigator.of(ctx).pop()), ), const SizedBox(width: 10), Expanded( flex: 2, child: DsButton('保存并连接', variant: DsBtnVariant.primary, onPressed: () async { if (nameCtrl.text.trim().isEmpty) { _snack('请填写设备名称', err: true); return; } Navigator.of(ctx).pop(); final items = peripheralsOf( ref.read(shopInfoProvider).valueOrNull?.customFields ?? const {}); try { await _savePeripherals([ ...items, Peripheral( name: nameCtrl.text.trim(), kind: kind, model: modelCtrl.text.trim(), conn: conn, ), ]); if (mounted) _snack('设备已添加并连接 ✓'); } catch (e) { if (mounted) _snack('添加失败:$e', err: true); } }), ), ]), ], ); }), ); } /// 原型 .sec-title:17/700 heading + small 12 muted,margin 26 0 14(首个 0 顶距)。 Widget _secTitle(dynamic t, String title, String small, {bool first = false}) => Padding( padding: EdgeInsets.only(top: first ? 0 : 26, bottom: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(title, style: TextStyle( fontSize: AppDims.fsH2, fontWeight: FontWeight.w700, color: t.heading)), const SizedBox(width: 10), Padding( padding: const EdgeInsets.only(bottom: 1), child: Text(small, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ), ], ), ); Widget _emptyHint(dynamic t, String msg) => Padding( padding: const EdgeInsets.symmetric(vertical: 20), child: Text(msg, style: TextStyle(color: t.muted, fontSize: AppDims.fsBody)), ); // ── 区块 A:登录设备(会话表)────────────────────────────── Widget _sessionSection() { final t = context.tokens; final async = ref.watch(sessionListProvider); final role = ref.watch(authStateProvider.select((s) => s.user?.role)); final canKick = role == 'admin' || role == 'superadmin'; return async.when( loading: () => const Padding( padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()), ), error: (e, _) => Row(children: [ _emptyHint(t, '会话加载失败'), const SizedBox(width: 10), DsButton('重试', small: true, onPressed: () => ref.read(sessionListProvider.notifier).reload()), ]), data: (sessions) { String fmt(DateTime? d) => d == null ? '—' : _fmt.format(d); return DsTable( shrinkWrap: true, emptyText: '暂无登录设备', columns: const [ DsColumn('user', '用户'), DsColumn('platform', '平台'), DsColumn('device', '设备'), DsColumn('ip', 'IP'), DsColumn('login', '登录时间'), DsColumn('active', '最近活跃'), DsColumn('status', '状态'), DsColumn('actions', '操作', action: true), ], rows: sessions.map((s) { return DsRow(cells: [ Row(mainAxisSize: MainAxisSize.min, children: [ // 弹性列(列0)被等宽列挤压时名字截断省略,防 Row 溢出 Flexible( child: Text(_sessionName(s), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.w600, color: t.heading)), ), if (s.isCurrent) ...[ const SizedBox(width: 6), // 原型 .self-tag:高18 pad 0 7 info-soft/primary 11/600 Container( height: 18, padding: const EdgeInsets.symmetric(horizontal: 7), alignment: Alignment.center, decoration: BoxDecoration( color: t.infoSoft, borderRadius: BorderRadius.circular(AppDims.rPill), ), child: Text('本机', style: TextStyle( fontSize: AppDims.fsXs, fontWeight: FontWeight.w600, color: t.primary)), ), ], ]), Text('${s.platformLabel} · ${s.platformClassLabel}'), Text(s.deviceName.isEmpty ? s.platform : s.deviceName, style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontSize: AppDims.fsSm, color: t.muted)), Text(s.ip.isEmpty ? '—' : s.ip, style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), Text(fmt(s.createdAt), style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontSize: AppDims.fsSm, color: t.muted)), Text(fmt(s.lastSeenAt), style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontSize: AppDims.fsSm, color: t.muted)), DsBadge(s.online ? '在线' : '离线', tone: s.online ? DsBadgeTone.ok : DsBadgeTone.muted), _kickCell(s, canKick), ]); }).toList(), ); }, ); } String _sessionName(DeviceSession s) => s.username.isEmpty ? '用户#${s.userId}' : s.username; Widget _kickCell(DeviceSession s, bool canKick) { final t = context.tokens; if (s.isCurrent) { return Text('当前设备', style: TextStyle(fontSize: AppDims.fsSm, color: t.faint)); } if (!canKick) return Text('—', style: TextStyle(color: t.faint)); return InkWell( key: Key('btn_kick_${s.id}'), onTap: () => _confirmKick(s), child: Text('强制下线', style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, color: t.danger)), ); } Future _confirmKick(DeviceSession s) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('强制下线'), content: Text('确认将「${_sessionName(s)}」的${s.platformLabel}设备下线?' '该设备约 30 秒内退出登录。'), actions: [ DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)), DsButton('强制下线', variant: DsBtnVariant.danger, onPressed: () => Navigator.of(ctx).pop(true)), ], ), ); if (confirmed != true) return; try { await ref.read(sessionListProvider.notifier).forceLogout(s.id); if (mounted) _snack('已强制下线 · ${_sessionName(s)}'); } catch (e) { if (mounted) _snack('操作失败:$e', err: true); } } // ── 区块 B:外设卡片网格(原型 .dev-grid minmax(300,1fr) gap14)──── Widget _devGrid(List items) { final t = context.tokens; if (items.isEmpty) return _emptyHint(t, '还没有外设 · 点「添加设备」登记'); return LayoutBuilder(builder: (ctx, cons) { const gap = 14.0; final cols = (cons.maxWidth / 314).floor().clamp(1, 4); final w = (cons.maxWidth - gap * (cols - 1)) / cols; return Wrap( spacing: gap, runSpacing: gap, children: [ for (var i = 0; i < items.length; i++) SizedBox(width: w, child: _devCard(items, i)), ], ); }); } Widget _devCard(List items, int index) { final t = context.tokens; final d = items[index]; final online = d.status == '在线'; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // .dev-top:42×42 图标块 + 名称/类型 + 状态徽章 Row(children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: t.infoSoft, borderRadius: BorderRadius.circular(AppDims.rMd), ), child: Icon(d.icon, size: 22, color: t.primary), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(d.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppDims.fsTitle, fontWeight: FontWeight.w700, color: t.heading)), Text(d.kind, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ], ), ), DsBadge(d.status, tone: online ? DsBadgeTone.ok : DsBadgeTone.muted), ]), // .dev-rows:型号 / 连接方式 / 最近活动 Container( margin: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 10), decoration: BoxDecoration( border: Border(top: BorderSide(color: t.borderSubtle))), child: Column(children: [ _devRow(t, '型号', d.model.isEmpty ? '—' : d.model), _devRow(t, '连接方式', d.conn, mono: true), _devRow(t, '最近活动', d.last.isEmpty ? '—' : d.last), ]), ), const SizedBox(height: 12), // .dev-foot:测试打印 / 配置 / 解绑 Row(children: [ Expanded( child: DsButton('测试打印', small: true, icon: LucideIcons.printer, onPressed: () => online ? _snack('已发送测试打印 → ${d.name}') : _snack('设备离线,无法测试 · ${d.name}', err: true))), const SizedBox(width: 8), Expanded( child: DsButton('配置', small: true, icon: LucideIcons.settings, onPressed: () => _snack('外设配置即将上线'))), const SizedBox(width: 8), Expanded( child: WriteGuard( child: DsButton('解绑', small: true, icon: LucideIcons.unlink, onPressed: () => _unbind(items, index)), ), ), ]), ], ), ); } Widget _devRow(dynamic t, String label, String value, {bool mono = false}) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), Text(value, style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, color: t.text, fontFamily: mono ? 'monospace' : null)), ], ), ); // ── 区块 C:打印模板(静态两卡,仅桌面;拍板移动端无打印)──── Widget _tplGrid(dynamic t) { final cards = [ _tplCard( t, LucideIcons.tag, '标签模板 · 商品价签', '40×30mm · 品名 / 规格 / 条码 / 零售价'), _tplCard(t, LucideIcons.receiptText, '小票模板 · 出库单据', '58mm 热敏 · 抬头 / 明细 / 合计 / 经手人'), ]; return Row(children: [ Expanded(child: cards[0]), const SizedBox(width: 14), Expanded(child: cards[1]), ]); } Widget _tplCard(dynamic t, IconData icon, String name, String desc) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: Row(children: [ // .tpl-prev 64×64 虚线框 Container( width: 64, height: 64, decoration: BoxDecoration( border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), color: t.bg, ), child: Icon(icon, size: 24, color: t.muted), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w700, color: t.heading)), const SizedBox(height: 2), Text(desc, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), const SizedBox(height: 8), Row(children: [ DsButton('预览', small: true, onPressed: () => _snack('模板预览即将上线')), const SizedBox(width: 8), DsButton('编辑', small: true, onPressed: () => _snack('模板编辑即将上线')), ]), ], ), ), ]), ); } // ── 外设增删(店级 custom_fields.peripherals)─────────────── Future _savePeripherals(List items) async { final shop = await ref.read(shopInfoProvider.future); final cf = Map.from(shop.customFields) ..['peripherals'] = items.map((p) => p.toMap()).toList(); await ref.read(shopRepositoryProvider).updateInfo({ 'name': shop.name, 'address': shop.address, 'phone': shop.phone, 'manager_name': shop.managerName, 'wechat_id': shop.wechatId, if (shop.logoUrl.isNotEmpty) 'logo_url': shop.logoUrl, 'custom_fields': cf, }); ref.invalidate(shopInfoProvider); } Future _unbind(List items, int index) async { final d = items[index]; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('解绑设备'), content: Text('确认解绑「${d.name}」?'), actions: [ DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)), DsButton('解绑', variant: DsBtnVariant.danger, onPressed: () => Navigator.of(ctx).pop(true)), ], ), ); if (ok != true || !mounted) return; try { final next = [...items]..removeAt(index); await _savePeripherals(next); if (mounted) _snack('已解绑 · ${d.name}'); } catch (e) { if (mounted) _snack('解绑失败:$e', err: true); } } // 添加设备弹窗(原型 ovAdd:类型+连接方式 / 名称* / 型号或地址) void _openAdd() { final nameCtrl = TextEditingController(); final modelCtrl = TextEditingController(); var kind = _kinds.first; var conn = _conns.first; final formKey = GlobalKey(); showAppDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setLocal) => AlertDialog( title: const Text('添加设备'), content: SizedBox( width: ctx.dialogWidth(560), child: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: DsField('设备类型', required: true, input: DsSelect( value: kind, options: [for (final k in _kinds) (k, k)], onChanged: (v) => setLocal(() => kind = v), )), ), const SizedBox(width: 14), Expanded( child: DsField('连接方式', required: true, input: DsSelect( value: conn, options: [for (final c in _conns) (c, c)], onChanged: (v) => setLocal(() => conn = v), )), ), ]), const SizedBox(height: 14), DsField('设备名称', required: true, input: TextFormField( controller: nameCtrl, decoration: const InputDecoration(hintText: '如:前台标签机'), validator: (v) => (v == null || v.trim().isEmpty) ? '不能为空' : null, )), const SizedBox(height: 14), DsField('型号 / 地址', input: TextFormField( controller: modelCtrl, decoration: const InputDecoration( hintText: '如:Zebra GK888t 或 192.168.1.50'), )), ], ), ), ), actions: [ DsButton('取消', onPressed: () => Navigator.of(ctx).pop()), DsButton('保存并连接', variant: DsBtnVariant.primary, onPressed: () async { if (!formKey.currentState!.validate()) return; Navigator.of(ctx).pop(); final items = peripheralsOf( ref.read(shopInfoProvider).valueOrNull?.customFields ?? const {}); try { await _savePeripherals([ ...items, Peripheral( name: nameCtrl.text.trim(), kind: kind, model: modelCtrl.text.trim(), conn: conn, ), ]); if (mounted) _snack('设备已添加并连接 ✓'); } catch (e) { if (mounted) _snack('添加失败:$e', err: true); } }), ], ), ), ); } }