feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏

- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-03 09:58:14 +08:00
parent ca7595b113
commit 6238b86dcb
286 changed files with 16831 additions and 11136 deletions
@@ -1,234 +1,746 @@
// screens/devices/device_management_screen.dart — 设备管理(照原型 devices.html 重建)。
// 三区块:登录设备管理(会话表,后端 /sessions) + 外设设备(卡片网格,店级
// custom_fields.peripherals 本地存档) + 打印模板(静态两卡)。无 KPI/toolbar/分页。
// 外设为登记式存档(用户口径):测试打印/配置为原型态占位,解绑=从清单删除。
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 '../../widgets/data_table_card.dart';
import '../../widgets/kpi_card.dart';
import '../../providers/shop_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/write_guard.dart';
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_toast.dart';
/// 设备 / 状态管理:本店在线设备/会话列表
/// 所有登录用户只读;管理员/超级管理员可「强制下线」其他登录者。
class DeviceManagementScreen extends ConsumerWidget {
/// 店级 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, WidgetRef ref) {
final asyncSessions = ref.watch(sessionListProvider);
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
final canKick = role == 'admin' || role == 'superadmin';
Widget build(BuildContext context) {
final t = context.tokens;
final mobile = context.isMobile;
final peripherals = peripheralsOf(
ref.watch(shopInfoProvider).valueOrNull?.customFields ?? const {});
final on = peripherals.where((p) => p.status == '在线').length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
child: Row(
children: [
const Text('设备 / 状态管理',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(width: 12),
Text(
canKick ? '管理员可强制下线其他登录设备' : '仅查看,无操作权限',
style: TextStyle(
fontSize: 12, color: context.tokens.muted),
),
const Spacer(),
IconButton(
tooltip: '刷新',
icon: const Icon(Icons.refresh, size: 20),
onPressed: () =>
ref.read(sessionListProvider.notifier).reload(),
),
],
),
),
Expanded(
child: asyncSessions.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
return Container(
color: t.bg,
child: SingleChildScrollView(
padding: mobile
? const EdgeInsets.all(AppDims.sp4)
// 原型 .main{padding:22px 26px}
: 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: [
Icon(Icons.error_outline,
size: 40, color: context.tokens.muted),
const SizedBox(height: 12),
Text('加载失败:$e',
style:
TextStyle(color: context.tokens.muted)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>
ref.read(sessionListProvider.notifier).reload(),
child: const Text('重试'),
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(),
if (!mobile) ...[
DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: () {
_reload();
_snack('设备状态已刷新 ✓');
}),
const SizedBox(width: 10),
WriteGuard(
child: DsButton('添加设备',
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: _openAdd),
),
],
],
),
),
data: (sessions) =>
_buildTable(context, ref, sessions, canKick),
),
),
],
);
}
Widget _buildTable(BuildContext context, WidgetRef ref,
List<DeviceSession> sessions, bool canKick) {
// 还原原型 .badge.b-在线/b-离线:圆点软底 pill。
Widget onlineBadge(bool online) => online
? StatusPill(
label: '在线',
color: context.tokens.success,
background: context.tokens.okSoft)
: StatusPill(
label: '离线',
color: context.tokens.muted,
background: context.tokens.infoSoft);
String fmt(DateTime? t) => t == null ? '-' : _fmt.format(t);
Widget? kickButton(DeviceSession s, {bool dense = false}) {
if (!canKick) return null;
if (s.isCurrent) {
return Text('当前设备',
style: TextStyle(fontSize: 12, color: context.tokens.muted));
}
return TextButton(
key: Key('btn_kick_${s.id}'),
onPressed: () => _confirmKick(context, ref, s),
child: Text('强制下线',
style: TextStyle(
fontSize: dense ? 13 : 12, color: context.tokens.danger)),
);
}
Widget sessionCard(DeviceSession s) {
final action = kickButton(s, dense: true);
return MobileListCard(
title: Text(s.username.isEmpty ? '用户#${s.userId}' : s.username),
subtitle: Text('${s.platformLabel} · ${s.platformClassLabel}'),
trailing: onlineBadge(s.online),
fields: [
if (s.deviceName.isNotEmpty) MobileCardField('设备', s.deviceName),
if (s.ip.isNotEmpty) MobileCardField('IP', s.ip),
MobileCardField('登录时间', fmt(s.createdAt)),
MobileCardField('最近活跃', fmt(s.lastSeenAt)),
if (s.isCurrent) const MobileCardField('备注', '当前设备'),
],
actions: action == null ? null : [action],
);
}
return DataTableCard(
mobileCards: sessions.map(sessionCard).toList(),
columns: const [
DataColumn(label: Text('用户')),
DataColumn(label: Text('平台')),
DataColumn(label: Text('设备')),
DataColumn(label: Text('IP')),
DataColumn(label: Text('登录时间')),
DataColumn(label: Text('最近活跃')),
DataColumn(label: Text('状态')),
DataColumn(label: Text('操作')),
],
rows: sessions.isEmpty
? [
DataRow(cells: [
DataCell(Text('暂无在线设备',
style: TextStyle(color: context.tokens.muted))),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
])
]
: sessions.map((s) {
return DataRow(cells: [
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(s.username.isEmpty ? '用户#${s.userId}' : s.username,
style:
const TextStyle(fontWeight: FontWeight.w500)),
if (s.isCurrent) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: context.tokens.primary.withAlpha(26),
borderRadius: BorderRadius.circular(4),
),
child: Text('本机',
style: TextStyle(
fontSize: 11, color: context.tokens.primary)),
),
],
// ── 区块 A:登录设备管理 ──
_secTitle(t, '登录设备管理', '管理员可强制下线其他登录设备', first: true),
_sessionSection(),
// ── 区块 B:外设设备 ──
_secTitle(t, '外设设备', '${peripherals.length} 台 · 在线 $on'),
if (mobile)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < peripherals.length; i++) ...[
if (i > 0) const SizedBox(height: 10),
_peripheralMobileCard(peripherals, i),
],
)),
DataCell(Text('${s.platformLabel} · ${s.platformClassLabel}')),
DataCell(Text(s.deviceName.isEmpty ? '-' : s.deviceName)),
DataCell(Text(s.ip.isEmpty ? '-' : s.ip,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: context.tokens.muted))),
DataCell(Text(fmt(s.createdAt))),
DataCell(Text(fmt(s.lastSeenAt))),
DataCell(onlineBadge(s.online)),
DataCell(kickButton(s) ?? const SizedBox()),
]);
}).toList(),
if (peripherals.isEmpty) _emptyHint(t, '还没有外设 · 点「添加设备」登记'),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerLeft,
child: WriteGuard(
child: DsButton('添加设备',
small: true,
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: _openAdd),
),
),
],
)
else
_devGrid(peripherals),
// ── 区块 C:打印模板 ──
_secTitle(t, '打印模板', '标签与小票排版'),
_tplGrid(t, mobile),
],
),
),
);
}
Future<void> _confirmKick(
BuildContext context, WidgetRef ref, DeviceSession s) async {
/// 原型 .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: '暂无登录设备',
mobileCards:
sessions.map((s) => _sessionMobileCard(s, canKick)).toList(),
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)),
);
}
Widget _sessionMobileCard(DeviceSession s, bool canKick) {
final t = context.tokens;
String fmt(DateTime? d) => d == null ? '' : _fmt.format(d);
return MobileListCard(
title: Text(_sessionName(s)),
subtitle: Text('${s.platformLabel} · ${s.platformClassLabel}'),
trailing: DsBadge(s.online ? '在线' : '离线',
tone: s.online ? DsBadgeTone.ok : DsBadgeTone.muted),
fields: [
if (s.ip.isNotEmpty) MobileCardField('IP', s.ip),
MobileCardField('登录时间', fmt(s.createdAt)),
MobileCardField('最近活跃', fmt(s.lastSeenAt)),
if (s.isCurrent) const MobileCardField('备注', '当前设备'),
],
actions: (canKick && !s.isCurrent)
? [
TextButton(
key: Key('btn_kick_${s.id}'),
onPressed: () => _confirmKick(s),
child: Text('强制下线',
style: TextStyle(fontSize: 13, color: t.danger)),
),
]
: null,
);
}
Future<void> _confirmKick(DeviceSession s) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('强制下线'),
content: Text(
'确认将「${s.username.isEmpty ? '用户#${s.userId}' : s.username}'
'${s.platformLabel}设备下线?该设备约 30 秒内退出登录。'),
content: Text('确认将「${_sessionName(s)}」的${s.platformLabel}设备下线?'
'该设备约 30 秒内退出登录。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: context.tokens.danger,
foregroundColor: Colors.white),
child: const Text('强制下线'),
),
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 (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: const Text('已下线'), backgroundColor: context.tokens.success));
}
if (mounted) _snack('已强制下线 · ${_sessionName(s)}');
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('操作失败:$e'), backgroundColor: context.tokens.danger));
}
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)),
],
),
);
Widget _peripheralMobileCard(List<Peripheral> items, int index) {
final d = items[index];
final online = d.status == '在线';
return MobileListCard(
title: Text(d.name),
subtitle: Text('${d.model.isEmpty ? d.kind : d.model} · ${d.kind}'),
trailing:
DsBadge(d.status, tone: online ? DsBadgeTone.ok : DsBadgeTone.muted),
fields: [
MobileCardField('连接方式', d.conn),
if (d.last.isNotEmpty) MobileCardField('最近活动', d.last),
],
actions: [
TextButton(
onPressed: () => online
? _snack('已发送测试打印 → ${d.name}')
: _snack('设备离线,无法测试 · ${d.name}', err: true),
child: const Text('测试打印', style: TextStyle(fontSize: 13)),
),
WriteGuard(
child: TextButton(
onPressed: () => _unbind(items, index),
child: Text('解绑',
style: TextStyle(fontSize: 13, color: context.tokens.danger)),
),
),
],
);
}
// ── 区块 C:打印模板(静态两卡)─────────────────────────────
Widget _tplGrid(dynamic t, bool mobile) {
final cards = [
_tplCard(
t, LucideIcons.tag, '标签模板 · 商品价签', '40×30mm · 品名 / 规格 / 条码 / 零售价'),
_tplCard(t, LucideIcons.receiptText, '小票模板 · 出库单据',
'58mm 热敏 · 抬头 / 明细 / 合计 / 经手人'),
];
if (mobile) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [cards[0], const SizedBox(height: 14), cards[1]],
);
}
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);
}
}),
],
),
),
);
}
}