Files
jiu/client/lib/screens/devices/device_management_screen.dart
T
wangjia 9445042cb4 feat(client): 设置/用户/设备/关于窄屏对齐移动原型(hub 形态/卡片流/sheet/无打印)
- 系统设置窄屏改 hub 三组(门店/偏好/数据):门店信息/编号规则/默认仓库走
  底部 sheet,主题外观接 showThemeSheet,?tab= 深链窄屏忽略;桌面 subnav 不动
- 用户管理窄屏:m-section + 头像卡片流 + 角色图标徽章 + FAB 新增;
  点卡开详情 sheet(drow + 重置密码/启停/编辑),表单复用为 sheet 形态
- 设备管理窄屏:会话卡流(本机标注/在线图标徽章)+ 外设卡流(mc-foot 脚注),
  「+ 添加设备」在区块标题行右侧(m-pill 选择 sheet),打印模板窄屏不渲染
- 关于我们窄屏:品牌 hero + hub 链接组 + 产品信息 drow 卡 + 时间线 pill 标签
  + 意见反馈 sheet(类型 pill/描述/联系方式);授权信息不显示(已迁 /me/license)
- golden:四屏窄屏基准独立成 *_mobile_golden_test.dart(390×844 @2x 三主题),
  桌面 golden 零漂移

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:19:15 +08:00

1127 lines
42 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<String, dynamic> toMap() => {
'name': name,
'kind': kind,
'model': model,
'conn': conn,
'status': status,
'last': last,
};
factory Peripheral.fromMap(Map<String, dynamic> 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<Peripheral> peripheralsOf(Map<String, dynamic> customFields) => [
for (final m in (customFields['peripherals'] as List? ?? const []))
if (m is Map) Peripheral.fromMap(Map<String, dynamic>.from(m)),
];
class DeviceManagementScreen extends ConsumerStatefulWidget {
const DeviceManagementScreen({super.key});
@override
ConsumerState<DeviceManagementScreen> createState() =>
_DeviceManagementScreenState();
}
class _DeviceManagementScreenState
extends ConsumerState<DeviceManagementScreen> {
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<Peripheral> 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<void>(
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<Peripheral> 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)),
]),
),
],
),
);
}
/// 外设详情 sheetdrow 键值 + 测试/配置/解绑)。
void _openPeripheralSheet(List<Peripheral> items, int index) {
final d = items[index];
final online = d.status == '在线';
showMSheet<void>(
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 .drowlabel 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(原型 drawAddm-pill 选类型/连接 + 名称/型号 + 保存)。
void _openAddSheet() {
final nameCtrl = TextEditingController();
final modelCtrl = TextEditingController();
var kind = _kinds.first;
var conn = _conns.first;
showMSheet<void>(
context,
title: '添加设备',
builder: (ctx) => StatefulBuilder(builder: (ctx, setLocal) {
final t = ctx.tokens;
Widget pills(String label, List<String> opts, String sel,
ValueChanged<String> 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-pillh32 / 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-title17/700 heading + small 12 mutedmargin 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<void> _confirmKick(DeviceSession s) async {
final confirmed = await showDialog<bool>(
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<Peripheral> 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<Peripheral> 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-top42×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<void> _savePeripherals(List<Peripheral> items) async {
final shop = await ref.read(shopInfoProvider.future);
final cf = Map<String, dynamic>.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<void> _unbind(List<Peripheral> items, int index) async {
final d = items[index];
final ok = await showDialog<bool>(
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<FormState>();
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<String>(
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<String>(
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);
}
}),
],
),
),
);
}
}