Files
jiu/client/lib/screens/devices/device_management_screen.dart
T
wangjia 6238b86dcb 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
2026-07-03 09:58:14 +08:00

747 lines
28 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/分页。
// 外设为登记式存档(用户口径):测试打印/配置为原型态占位,解绑=从清单删除。
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/mobile_list_card.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 mobile = context.isMobile;
final peripherals = peripheralsOf(
ref.watch(shopInfoProvider).valueOrNull?.customFields ?? const {});
final on = peripherals.where((p) => p.status == '在线').length;
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: [
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),
),
],
],
),
),
// ── 区块 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),
],
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),
],
),
),
);
}
/// 原型 .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('确认将「${_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)),
],
),
);
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);
}
}),
],
),
),
);
}
}