9445042cb4
- 系统设置窄屏改 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>
1127 lines
42 KiB
Dart
1127 lines
42 KiB
Dart
// 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)),
|
||
]),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 外设详情 sheet(drow 键值 + 测试/配置/解绑)。
|
||
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 .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<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-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<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-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<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);
|
||
}
|
||
}),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|