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>
This commit is contained in:
wangjia
2026-07-04 12:19:15 +08:00
parent dde87a32b6
commit 9445042cb4
24 changed files with 1975 additions and 311 deletions
@@ -2,6 +2,9 @@
// 三区块:登录设备管理(会话表,后端 /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';
@@ -17,7 +20,8 @@ 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/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';
@@ -100,18 +104,18 @@ class _DeviceManagementScreenState
@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;
// ── 窄屏(原型 m-devices.html):会话卡流 + 外设卡流,无页头/打印模板 ──
if (context.isMobile) return _mobileBody(t, peripherals);
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),
// 原型 .main{padding:22px 26px}
padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -134,19 +138,17 @@ class _DeviceManagementScreenState
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),
),
],
DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: () {
_reload();
_snack('设备状态已刷新 ✓');
}),
const SizedBox(width: 10),
WriteGuard(
child: DsButton('添加设备',
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: _openAdd),
),
],
),
),
@@ -155,39 +157,482 @@ class _DeviceManagementScreenState
_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),
_devGrid(peripherals),
// ── 区块 C:打印模板 ──
_secTitle(t, '打印模板', '标签与小票排版'),
_tplGrid(t, mobile),
_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}) =>
@@ -241,8 +686,6 @@ class _DeviceManagementScreenState
return DsTable(
shrinkWrap: true,
emptyText: '暂无登录设备',
mobileCards:
sessions.map((s) => _sessionMobileCard(s, canKick)).toList(),
columns: const [
DsColumn('user', '用户'),
DsColumn('platform', '平台'),
@@ -337,33 +780,6 @@ class _DeviceManagementScreenState
);
}
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,
@@ -513,50 +929,14 @@ class _DeviceManagementScreenState
),
);
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) {
// ── 区块 C:打印模板(静态两卡,仅桌面;拍板移动端无打印)────
Widget _tplGrid(dynamic t) {
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),