diff --git a/client/lib/screens/about/about_screen.dart b/client/lib/screens/about/about_screen.dart index 62cc95b..9557374 100644 --- a/client/lib/screens/about/about_screen.dart +++ b/client/lib/screens/about/about_screen.dart @@ -3,6 +3,9 @@ // 授权信息(3 cell, licenseProvider 实数据) / 更新日志(timeline, /public/release // 与官网同源,默认 2 条可展开)。旧版多余卡(帮助文档/扫码防伪/系统信息)删除, // 构建号并入版本 cell 小字(差异记 design/CONTRACT.md)。 +// 窄屏(原型 m-about.html):品牌 hero(图标+名+版本+检查更新+反馈) + hub 链接组 + +// 产品信息卡(drow) + 更新日志时间线(pill 标签) + 反馈 sheet + 页脚备案; +// 授权信息窄屏不显示(已迁 /me/license)。 import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -23,6 +26,8 @@ import '../../core/theme/app_fonts.dart'; import '../../widgets/brand_mark.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_toast.dart'; +import '../../widgets/ds/m_hub.dart'; +import '../../widgets/ds/m_sheet.dart'; class AboutScreen extends ConsumerStatefulWidget { const AboutScreen({super.key}); @@ -38,12 +43,12 @@ class _AboutScreenState extends ConsumerState { @override Widget build(BuildContext context) { final t = context.tokens; + // ── 窄屏(原型 m-about.html):hero + hub 链接组 + 产品信息 + 时间线 ── + if (context.isMobile) return _mobileBody(t); return Container( color: t.bg, child: SingleChildScrollView( - padding: context.isMobile - ? const EdgeInsets.all(AppDims.sp4) - : const EdgeInsets.fromLTRB(26, 22, 26, 22), + padding: const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 760), @@ -80,6 +85,284 @@ class _AboutScreenState extends ConsumerState { ); } + // ── 窄屏整体(原型 m-about.html)──────────────────────────── + Widget _mobileBody(dynamic t) { + return Container( + color: t.bg, + child: ListView( + padding: const EdgeInsets.all(14), + children: [ + _mHero(t), + // hub 链接组(原型 .m-hub margin-top:10) + Padding( + padding: const EdgeInsets.only(top: 10), + child: MHubGroup(items: [ + MHubItem( + icon: LucideIcons.globe, + label: '官方网站', + onTap: () => _open(AppInfo.website)), + MHubItem( + icon: LucideIcons.messageCircle, + label: '微信客服', + onTap: () => showDsToast(context, + '微信客服:${AppInfo.wechat.isNotEmpty ? AppInfo.wechat : '请见官网'}')), + MHubItem( + // 原型 m-about 用 i-shield-2 + icon: LucideIcons.shield, + label: '隐私政策', + onTap: () => _open(AppInfo.privacyUrl.isNotEmpty + ? AppInfo.privacyUrl + : '${AppInfo.website}/privacy/')), + MHubItem( + icon: LucideIcons.fileText, + label: '服务条款', + onTap: () => _open(AppInfo.termsUrl.isNotEmpty + ? AppInfo.termsUrl + : '${AppInfo.website}/terms/')), + ]), + ), + _mSection(t, '产品信息'), + _mProductCard(t), + _mSection(t, '更新日志'), + _mChangelogCard(t), + // 页脚 © + 备案号 + Padding( + padding: const EdgeInsets.fromLTRB(0, 18, 0, 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('© 2026 岩美科技 · 保留所有权利 · ', + style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), + InkWell( + onTap: () => + launchUrl(Uri.parse('https://beian.miit.gov.cn')), + child: Text('京ICP备2026039814号', + style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), + ), + ], + ), + ), + ], + ), + ); + } + + /// .m-section + Widget _mSection(dynamic t, String label) => Padding( + padding: const EdgeInsets.fromLTRB(2, 16, 2, 8), + child: Text(label, + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w700, + letterSpacing: .4, + color: t.muted)), + ); + + /// 原型 .ab-hero:居中品牌图标 + 名称 + 版本 + 检查更新 + 反馈双按钮。 + Widget _mHero(dynamic t) { + final appVersion = ref.watch(appVersionProvider).valueOrNull ?? ''; + return Container( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 16), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rLg), + ), + child: Column(children: [ + const BrandMark(size: 72), + const SizedBox(height: 14), + Text('岩美酒库', + style: TextStyle( + fontSize: AppDims.fsH1, + fontWeight: FontWeight.w800, + color: t.heading)), + const SizedBox(height: 4), + // 版本号 mono、中文说明走默认字体(mono 无 CJK 字形) + Row(mainAxisSize: MainAxisSize.min, children: [ + if (appVersion.isNotEmpty) + Text('$appVersion · ', + style: TextStyle( + fontSize: AppDims.fsSm, + color: t.muted, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback)), + Text('酒水进销存管理系统', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + ]), + const SizedBox(height: 10), + DsSmallGhostButton( + label: '检查更新', icon: LucideIcons.refreshCw, onTap: _checkUpdate), + const SizedBox(height: 18), + Row(children: [ + Expanded( + child: DsButton('反馈 Bug', + icon: LucideIcons.bug, + variant: DsBtnVariant.primary, + onPressed: () => _openFeedbackSheet('Bug')), + ), + const SizedBox(width: 10), + Expanded( + child: DsButton('功能建议', + icon: LucideIcons.lightbulb, + onPressed: () => _openFeedbackSheet('功能建议')), + ), + ]), + ]), + ); + } + + /// 产品信息卡(原型 .pe-card + drow:出品方 / 多端覆盖 / 产品定位)。 + Widget _mProductCard(dynamic t) { + Widget drow(String label, String value, {bool last = false}) => Container( + padding: const EdgeInsets.symmetric(vertical: 11), + decoration: BoxDecoration( + border: + last ? null : Border(bottom: BorderSide(color: t.borderSubtle)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), + const SizedBox(width: 12), + Expanded( + child: Text(value, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + color: t.text)), + ), + ], + ), + ); + return Container( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 2), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rLg), + ), + child: Column(children: [ + drow('出品方', '岩美科技 · YANMEI TECH'), + drow('多端覆盖', 'Web · macOS · Windows · iOS · 安卓'), + drow('产品定位', '酒水进销存 / 财务 / 防伪溯源', last: true), + ]), + ); + } + + /// 更新日志卡(原型 .tl 时间线:竖线 + 圆点 + 版本/日期 + pill 分组标签)。 + Widget _mChangelogCard(dynamic t) { + final appVersion = ref.watch(appVersionProvider).valueOrNull ?? ''; + final entries = ref.watch(changelogProvider).valueOrNull ?? const []; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rLg), + ), + child: entries.isEmpty + ? Text(appVersion.isEmpty ? '暂无更新记录' : '当前版本 $appVersion', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)) + : Column(children: [ + for (var i = 0; i < entries.length; i++) + _mTlItem(t, entries[i], last: i == entries.length - 1), + ]), + ); + } + + /// 原型 .tl-item:左 2px 竖线 + 8px primary 圆点 + 版本/日期 + 分组。 + Widget _mTlItem(dynamic t, ChangelogEntry e, {required bool last}) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 16, + child: Column(children: [ + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(top: 4), + decoration: + BoxDecoration(shape: BoxShape.circle, color: t.primary), + ), + if (!last) + Expanded(child: Container(width: 2, color: t.borderSubtle)), + ]), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Text('v${e.version}', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: AppDims.fsBody, + color: t.heading)), + const SizedBox(width: 8), + Text(e.date, + style: TextStyle( + fontSize: AppDims.fsXs, + color: t.faint, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback)), + ]), + for (final s in e.sections) ...[ + const SizedBox(height: 8), + _mTlTag(t, s.type), + for (final item in s.items) + Padding( + padding: const EdgeInsets.only(top: 5, left: 2), + child: Text('· $item', + style: TextStyle( + fontSize: AppDims.fsSm, + height: 1.5, + color: t.text)), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + /// 原型 .tlt:pill 标签(feat=success-bg / impr=info-soft / fix=warn-bg)。 + Widget _mTlTag(dynamic t, String type) { + final (label, bg, fg) = switch (type) { + 'feat' || '新功能' => ('新功能', t.successBg, t.success), + 'impr' || 'improve' || '改进' => ('改进', t.infoSoft, t.primary), + 'fix' || '修复' => ('修复', t.warnBg, t.warn), + _ => (type, t.borderSubtle, t.muted), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(AppDims.rPill), + ), + child: Text(label, + style: TextStyle( + fontSize: AppDims.fsXs, fontWeight: FontWeight.w600, color: fg)), + ); + } + + /// 意见反馈 sheet(原型 openFb:类型 pill Bug/功能建议/其他 + 描述 + 联系方式)。 + void _openFeedbackSheet(String initialType) { + showMSheet( + context, + title: '意见反馈', + builder: (_) => _FeedbackSheetBody(initialType: initialType), + ); + } + /// 原型 .card:surface / border / r-lg / pad 20 22 / mb18。 Widget _card(dynamic t, Widget child, {Gradient? gradient}) => Container( margin: const EdgeInsets.only(bottom: 18), @@ -560,6 +843,131 @@ class DsSmallGhostButton extends StatelessWidget { } } +/// 窄屏意见反馈 sheet 内容(原型 m-about drawFb:类型 pill + 描述 + 联系方式)。 +/// 提交沿用 feedbackRepository.submit(联系方式并入 content,接口无独立字段)。 +class _FeedbackSheetBody extends ConsumerStatefulWidget { + final String initialType; // Bug / 功能建议 / 其他 + const _FeedbackSheetBody({required this.initialType}); + + @override + ConsumerState<_FeedbackSheetBody> createState() => _FeedbackSheetBodyState(); +} + +class _FeedbackSheetBodyState extends ConsumerState<_FeedbackSheetBody> { + static const _types = ['Bug', '功能建议', '其他']; + late String _type = + _types.contains(widget.initialType) ? widget.initialType : _types.first; + final _descCtrl = TextEditingController(); + final _contactCtrl = TextEditingController(); + bool _submitting = false; + + @override + void dispose() { + _descCtrl.dispose(); + _contactCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_descCtrl.text.trim().isEmpty) { + showDsToast(context, '请先填写反馈内容'); + return; + } + setState(() => _submitting = true); + final apiType = switch (_type) { + 'Bug' => 'bug', + '功能建议' => 'suggestion', + _ => 'other', + }; + final contact = _contactCtrl.text.trim(); + final content = contact.isEmpty + ? _descCtrl.text.trim() + : '${_descCtrl.text.trim()}\n联系方式:$contact'; + try { + await ref.read(feedbackRepositoryProvider).submit( + type: apiType, + content: content, + images: const [], + ); + if (!mounted) return; + Navigator.of(context).pop(); + showDsToast(context, '感谢反馈,我们会尽快跟进 ✓'); + } catch (e) { + if (!mounted) return; + setState(() => _submitting = false); + showDsToast(context, '提交失败:$e'); + } + } + + @override + Widget build(BuildContext context) { + final t = context.tokens; + Widget label(String s) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: + Text(s, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + label('反馈类型'), + Wrap(spacing: 8, children: [ + for (final v in _types) + // 原型 .m-pill:h32 / r-pill / on=brand50+primary + InkWell( + onTap: () => setState(() => _type = v), + borderRadius: BorderRadius.circular(AppDims.rPill), + child: Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 13), + alignment: Alignment.center, + decoration: BoxDecoration( + color: v == _type ? t.brand50 : t.surface, + border: Border.all(color: v == _type ? t.primary : t.border), + borderRadius: BorderRadius.circular(AppDims.rPill), + ), + child: Text(v, + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: + v == _type ? FontWeight.w600 : FontWeight.w400, + color: v == _type ? t.primary : t.text)), + ), + ), + ]), + const SizedBox(height: 14), + label('问题描述'), + TextField( + controller: _descCtrl, + maxLines: 4, + decoration: const InputDecoration( + hintText: '请描述遇到的问题或建议…', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 14), + label('联系方式(选填)'), + DsInput(controller: _contactCtrl, hintText: '微信 / 手机号,便于我们回访'), + const SizedBox(height: 16), + Row(children: [ + Expanded( + child: DsButton('取消', + onPressed: + _submitting ? null : () => Navigator.of(context).pop()), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: DsButton(_submitting ? '提交中…' : '提交', + variant: DsBtnVariant.primary, + onPressed: _submitting ? null : _submit), + ), + ]), + ], + ); + } +} + /// 反馈表单弹窗:文字 + 附图,直接提交后台(沿用原实现)。 class _FeedbackDialog extends ConsumerStatefulWidget { final String type; // bug / suggestion diff --git a/client/lib/screens/devices/device_management_screen.dart b/client/lib/screens/devices/device_management_screen.dart index 50affd9..f7d39c5 100644 --- a/client/lib/screens/devices/device_management_screen.dart +++ b/client/lib/screens/devices/device_management_screen.dart @@ -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 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( + 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 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 items, int index) { + final d = items[index]; + final online = d.status == '在线'; + showMSheet( + 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( + context, + title: '添加设备', + builder: (ctx) => StatefulBuilder(builder: (ctx, setLocal) { + final t = ctx.tokens; + Widget pills(String label, List opts, String sel, + ValueChanged 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}) => @@ -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 _confirmKick(DeviceSession s) async { final confirmed = await showDialog( context: context, @@ -513,50 +929,14 @@ class _DeviceManagementScreenState ), ); - Widget _peripheralMobileCard(List 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), diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index de5d1a8..6526844 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -3,6 +3,9 @@ // 原型 4(门店信息/用户管理/授权管理/偏好设置)+ 保留真实功能「编号规则」。 // 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。 // 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。 +// 窄屏(原型 m-settings.html):hub 形态三组(门店/偏好/数据),标题在壳顶栏; +// 门店信息/编号规则/默认仓库走底部 sheet;?tab= 深链窄屏忽略(无面板可落)。 +// 授权入口不在此(拍板:授权在「我的」系统组 /me/license)。 import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -25,6 +28,9 @@ import '../../providers/warehouse_provider.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_menu.dart'; import '../../widgets/ds/ds_table.dart'; +import '../../widgets/ds/m_hub.dart'; +import '../../widgets/ds/m_sheet.dart'; +import '../../widgets/theme_sheet.dart'; import '../../widgets/write_guard.dart'; import 'license_panel.dart'; import 'settings_card.dart'; @@ -56,20 +62,17 @@ class _SettingsScreenState extends ConsumerState { @override Widget build(BuildContext context) { final t = context.tokens; - final mobile = context.isMobile; + // 窄屏:hub 形态(原型 m-settings.html);?tab= 深链在窄屏无面板可落,忽略 + if (context.isMobile) return _mobileHub(t); return Container( color: t.bg, - padding: - mobile ? EdgeInsets.zero : const EdgeInsets.fromLTRB(26, 22, 26, 22), + padding: const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── 头部(原型 .head)── Padding( - padding: mobile - ? const EdgeInsets.fromLTRB( - AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2) - : const EdgeInsets.only(bottom: 18), + padding: const EdgeInsets.only(bottom: 18), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -87,42 +90,224 @@ class _SettingsScreenState extends ConsumerState { ], ), ), - if (mobile) - Padding( - padding: - const EdgeInsets.fromLTRB(AppDims.sp4, 0, AppDims.sp4, 12), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DsSeg( - items: [for (final n in _navs) n.$2], - index: _panel, - onChanged: (i) => setState(() => _panel = i), - ), - ), - ), Expanded( - child: mobile - ? SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - AppDims.sp4, 0, AppDims.sp4, AppDims.sp4), - child: _panelBody(), - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _subnav(t), - const SizedBox(width: 22), - Expanded( - child: SingleChildScrollView(child: _panelBody()), - ), - ], - ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _subnav(t), + const SizedBox(width: 22), + Expanded( + child: SingleChildScrollView(child: _panelBody()), + ), + ], + ), ), ], ), ); } + // ── 窄屏 hub(原型 m-settings.html:门店/偏好/数据 三组)───────── + Widget _mobileHub(dynamic t) { + return Container( + color: t.bg, + child: ListView( + padding: const EdgeInsets.all(14), + children: [ + MHubGroup(label: '门店', items: [ + // 原型 i-ic57(门店信息)→ house,与桌面 subnav 同源 + MHubItem( + icon: LucideIcons.house, label: '门店信息', onTap: _openShopSheet), + MHubItem( + icon: LucideIcons.userPlus, + label: '用户与角色', + onTap: () => context.push('/settings/users')), + MHubItem( + icon: LucideIcons.hash, label: '编号规则', onTap: _openRulesSheet), + ]), + MHubGroup(label: '偏好', items: [ + MHubItem( + icon: LucideIcons.shirt, + label: '主题外观', + onTap: () => showThemeSheet(context)), + MHubItem( + icon: LucideIcons.warehouse, + label: '默认仓库', + onTap: _openWarehouseSheet), + MHubItem( + icon: LucideIcons.monitorSmartphone, + label: '设备管理', + onTap: () => context.go('/devices')), + ]), + MHubGroup(label: '数据', items: [ + MHubItem( + icon: LucideIcons.cloud, + label: '数据备份', + onTap: () => showDsToast(context, '云端每日自动备份,无需手动操作')), + MHubItem( + icon: LucideIcons.info, + label: '关于与版本', + onTap: () => context.go('/about')), + ]), + ], + ), + ); + } + + /// 门店信息 sheet(原型 openShopSheet:名称/编号只读/电话/微信/地址 + 保存)。 + void _openShopSheet() { + showMSheet( + context, + title: '门店信息', + builder: (_) => const _ShopInfoSheetBody(), + ); + } + + /// 编号规则 sheet(原型 openRulesSheet:notice + drow 行;编辑走既有 dialog)。 + void _openRulesSheet() { + showMSheet( + context, + title: '编号规则', + builder: (_) => Consumer(builder: (ctx, ref, _) { + final t = ctx.tokens; + final async = ref.watch(numberRuleListProvider); + return async.when( + loading: () => const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator())), + error: (e, _) => Text('加载失败', + style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)), + data: (rules) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 原型 .notice.info + Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: t.infoSoft, + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row(children: [ + Icon(LucideIcons.info, size: 16, color: t.primary), + const SizedBox(width: 8), + Expanded( + child: Text('单据编号自动生成,规则全店统一;序号按规则递增。', + style: + TextStyle(fontSize: AppDims.fsSm, color: t.text)), + ), + ]), + ), + if (rules.isEmpty) + Text('暂无编号规则', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + for (var i = 0; i < rules.length; i++) + Container( + padding: const EdgeInsets.symmetric(vertical: 11), + decoration: BoxDecoration( + border: i == rules.length - 1 + ? null + : Border(bottom: BorderSide(color: t.borderSubtle)), + ), + child: Row(children: [ + Text(rules[i].typeLabel, + style: TextStyle( + fontSize: AppDims.fsBody, color: t.muted)), + const Spacer(), + Text(rules[i].exampleNo, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + color: t.text)), + WriteGuard( + child: Padding( + padding: const EdgeInsets.only(left: 10), + child: InkWell( + onTap: () => _editRule(rules[i]), + child: Icon(LucideIcons.pencil, + size: 15, color: t.muted), + ), + ), + ), + ]), + ), + ], + ), + ); + }), + ); + } + + /// 默认仓库 sheet(原型 openWhSheet:m-opt 行选择,点选即保存)。 + void _openWarehouseSheet() { + showMSheet( + context, + title: '默认仓库', + builder: (_) => Consumer(builder: (ctx, ref, _) { + final t = ctx.tokens; + final readonly = WriteGuard.isReadonly(ref); + final warehouses = + ref.watch(warehouseListProvider).valueOrNull ?? const []; + if (warehouses.isEmpty) { + return Text('暂无仓库', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)); + } + return Column(children: [ + for (var i = 0; i < warehouses.length; i++) + Builder(builder: (_) { + final w = warehouses[i]; + return InkWell( + // 只读角色不可改默认仓库(与桌面 WriteGuard 同口径) + onTap: readonly + ? null + : () async { + Navigator.of(ctx).pop(); + if (w.isDefault) return; + try { + await ref + .read(warehouseListProvider.notifier) + .updateWarehouse(w.id, {'is_default': true}); + if (mounted) { + showDsToast(context, '默认仓库:${w.name} ✓', + bg: context.tokens.success); + } + } catch (e) { + if (mounted) { + showDsToast(context, '设置失败:$e', + bg: context.tokens.danger); + } + } + }, + child: Container( + padding: const EdgeInsets.fromLTRB(4, 13, 4, 13), + decoration: BoxDecoration( + border: i == warehouses.length - 1 + ? null + : Border(bottom: BorderSide(color: t.borderSubtle)), + ), + child: Row(children: [ + Expanded( + child: Text(w.name, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: w.isDefault + ? FontWeight.w600 + : FontWeight.w400, + color: w.isDefault ? t.primary : t.text)), + ), + if (w.isDefault) + Icon(LucideIcons.check, size: 18, color: t.primary), + ]), + ), + ); + }), + ]); + }), + ); + } + /// 原型 .subnav:200px surface r-lg pad8;项高 40 gap10;on=brand50/primary/600。 Widget _subnav(dynamic t) { return Container( @@ -359,6 +544,148 @@ class _SettingsScreenState extends ConsumerState { } } +// ── 窄屏:门店信息 sheet 表单(逻辑同 _StoreInfoPanel,logo 上传窄屏省略)── +class _ShopInfoSheetBody extends ConsumerStatefulWidget { + const _ShopInfoSheetBody(); + + @override + ConsumerState<_ShopInfoSheetBody> createState() => _ShopInfoSheetBodyState(); +} + +class _ShopInfoSheetBodyState extends ConsumerState<_ShopInfoSheetBody> { + final _nameCtrl = TextEditingController(); + final _phoneCtrl = TextEditingController(); + final _wechatCtrl = TextEditingController(); + final _addressCtrl = TextEditingController(); + ShopInfo? _loaded; + bool _saving = false; + + @override + void dispose() { + _nameCtrl.dispose(); + _phoneCtrl.dispose(); + _wechatCtrl.dispose(); + _addressCtrl.dispose(); + super.dispose(); + } + + void _fill(ShopInfo shop) { + _loaded = shop; + _nameCtrl.text = shop.name; + _phoneCtrl.text = shop.phone; + _wechatCtrl.text = shop.wechatId; + _addressCtrl.text = shop.address; + } + + Future _save() async { + final shop = _loaded; + if (shop == null) return; + setState(() => _saving = true); + try { + await ref.read(shopRepositoryProvider).updateInfo({ + 'name': _nameCtrl.text.trim(), + 'address': _addressCtrl.text.trim(), + 'phone': _phoneCtrl.text.trim(), + 'manager_name': shop.managerName, + 'wechat_id': _wechatCtrl.text.trim(), + if (shop.logoUrl.isNotEmpty) 'logo_url': shop.logoUrl, + 'custom_fields': shop.customFields, + }); + ref.invalidate(shopInfoProvider); + if (mounted) { + Navigator.of(context).pop(); + showDsToast(context, '门店信息已保存 ✓', bg: context.tokens.success); + } + } catch (e) { + if (mounted) { + showDsToast(context, '保存失败:$e', bg: context.tokens.danger); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final async = ref.watch(shopInfoProvider); + final role = ref.watch(authStateProvider.select((s) => s.user?.role)); + final canEdit = (role == 'admin' || role == 'superadmin') && + !WriteGuard.isReadonly(ref) && + !WriteGuard.licenseBlocked(ref); + + return async.when( + loading: () => const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator())), + error: (e, _) => Text('加载失败', + style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)), + data: (shop) { + if (_loaded?.code != shop.code || _loaded?.name != shop.name) { + _fill(shop); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _field('门店名称', DsInput(controller: _nameCtrl, enabled: canEdit)), + _field( + '门店编号(不可改)', + DsInput( + controller: TextEditingController(text: shop.code), + enabled: false)), + _field('联系电话', DsInput(controller: _phoneCtrl, enabled: canEdit)), + _field( + '微信', + DsInput( + controller: _wechatCtrl, + enabled: canEdit, + hintText: '微信号 / 客服微信')), + _field( + '门店地址', + DsInput( + controller: _addressCtrl, + enabled: canEdit, + hintText: '省 / 市 / 详细地址')), + const SizedBox(height: 4), + // 原型底排:取消 ghost(1) + 保存 primary(2) + Row(children: [ + Expanded( + child: DsButton(canEdit ? '取消' : '关闭', + onPressed: () => Navigator.of(context).pop()), + ), + if (canEdit) ...[ + const SizedBox(width: 10), + Expanded( + flex: 2, + child: DsButton(_saving ? '保存中…' : '保存', + variant: DsBtnVariant.primary, + onPressed: _saving ? null : _save), + ), + ], + ]), + ], + ); + }, + ); + } + + /// 原型 .m-field:label(fs-sm muted) 上、间距 6、下距 14。 + Widget _field(String label, Widget input) { + final t = context.tokens; + return 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), + input, + ], + ), + ); + } +} + // ── 面板 1:门店信息(内联表单)────────────────────────────── class _StoreInfoPanel extends ConsumerStatefulWidget { const _StoreInfoPanel(); diff --git a/client/lib/screens/settings/users_screen.dart b/client/lib/screens/settings/users_screen.dart index 4f6feef..3c7f318 100644 --- a/client/lib/screens/settings/users_screen.dart +++ b/client/lib/screens/settings/users_screen.dart @@ -2,6 +2,8 @@ // head + KPI 4 卡(无图标)+ toolbar(搜索 + 角色筛选)+ 表格(头像/角色徽章/ // 启停)+ 新增/编辑弹窗(角色 2×2 单选卡 + 状态开关 + 重置密码)。 // 角色口径按后端四级:superadmin/admin/operator/readonly(原型三处不一致已拉平)。 +// 窄屏(原型 m-users.html):隐藏页头/KPI/toolbar,m-section + 头像卡片流 + +// FAB 新增;点卡开详情 sheet(drow + 重置密码/启停/编辑),表单走 sheet 形态。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; @@ -18,7 +20,8 @@ import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_kpi.dart'; import '../../widgets/ds/ds_menu.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'; @@ -108,6 +111,8 @@ class _UsersScreenState extends ConsumerState { ), ), data: (users) { + // ── 窄屏(原型 m-users.html):m-section + 头像卡片流 + FAB 新增 ── + if (mobile) return _mobileBody(t, users); final kw = _searchCtrl.text.trim().toLowerCase(); final filtered = users.where((u) { if (_roleFilter != '全部角色' && u.roleLabel != _roleFilter) { @@ -171,7 +176,6 @@ class _UsersScreenState extends ConsumerState { pagerInfoText: '共 ${filtered.length} 名成员', emptyText: '没有匹配的成员', toolbar: _toolbar(mobile), - mobileCards: filtered.map(_userCard).toList(), columns: const [ DsColumn('user', '用户'), DsColumn('role', '角色'), @@ -350,19 +354,260 @@ class _UsersScreenState extends ConsumerState { ); } - Widget _userCard(AppUser u) { - return MobileListCard( - title: Text(u.realName?.isNotEmpty == true ? u.realName! : u.username), - subtitle: Text(u.username), - trailing: userRoleBadge(u.role), - fields: [ - MobileCardField('状态', u.isActive ? '启用' : '停用'), - MobileCardField('最近登录', _fmtLast(u.lastLoginAt)), - ], - onTap: () => _openUser(u), + // ── 窄屏(原型 m-users.html)──────────────────────────────── + Widget _mobileBody(dynamic t, List users) { + return Scaffold( + backgroundColor: t.bg, + body: ListView( + padding: const EdgeInsets.all(14), + children: [ + // .m-section + Padding( + padding: const EdgeInsets.fromLTRB(2, 2, 2, 8), + child: Text('成员 · 共 ${users.length}', + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w700, + letterSpacing: .4, + color: t.muted)), + ), + if (users.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text('暂无成员', + style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), + ), + for (final u in users) _mUserCard(t, u), + ], + ), + // 原型 .m-fab:52px primary 圆形悬浮新增(不走 M3 FAB 主题壳) + floatingActionButton: WriteGuard( + child: Material( + color: t.primary, + shape: const CircleBorder(), + child: InkWell( + onTap: () => _openUser(), + customBorder: const CircleBorder(), + child: SizedBox( + width: 52, + height: 52, + child: Icon(LucideIcons.plus, size: 24, color: t.onPrimary), + ), + ), + ), + ), ); } + /// 角色徽章(图标变体,原型 badge.bi:管理员=shield / 普通=user / 只读=eye)。 + DsBadge _roleBadgeIc(String role) { + // 图标词表按原型三级(icons.js BADGE_ICON 登记词),四级角色归并映射 + final protoWord = switch (role) { + 'superadmin' || 'admin' => '管理员', + 'readonly' => '只读', + _ => '普通', + }; + final tone = switch (role) { + 'superadmin' => DsBadgeTone.danger, + 'admin' => DsBadgeTone.info, + 'operator' => DsBadgeTone.ok, + 'readonly' => DsBadgeTone.warn, + _ => DsBadgeTone.muted, + }; + final label = switch (role) { + 'superadmin' => '超级管理员', + 'admin' => '管理员', + 'operator' => '操作员', + 'readonly' => '只读', + _ => role, + }; + return DsBadge(label, tone: tone, icon: statusIcon(protoWord)); + } + + /// 原型 .m-card:40 圆头像(.uav) + 姓名 + 账号(mono) + 角色徽章。 + Widget _mUserCard(dynamic t, AppUser u) { + final name = u.realName?.isNotEmpty == true ? u.realName! : u.username; + return 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: () => _openUserSheet(u), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), + child: Row(children: [ + Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: + BoxDecoration(color: t.brand50, shape: BoxShape.circle), + child: Text(name.characters.first, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w700, + color: t.primary)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + color: t.heading)), + const SizedBox(height: 3), + Text(u.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppDims.fsXs, + color: t.muted, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback)), + ], + ), + ), + const SizedBox(width: 8), + _roleBadgeIc(u.role), + ]), + ), + ), + ), + ); + } + + /// 成员详情 sheet(原型 openU:drow 键值 + 重置密码/启停/编辑)。 + void _openUserSheet(AppUser u) { + final name = u.realName?.isNotEmpty == true ? u.realName! : u.username; + showMSheet( + context, + title: name, + builder: (ctx) { + final t = ctx.tokens; + Widget drow(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, + ]), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + drow( + '账号', + Text(u.username, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + color: t.text))), + drow('角色', _roleBadgeIc(u.role)), + drow( + '状态', + DsBadge(u.isActive ? '启用' : '停用', + tone: u.isActive ? DsBadgeTone.ok : DsBadgeTone.muted, + icon: statusIcon(u.isActive ? '启用' : '停用'))), + drow( + '最近登录', + Text(_fmtLast(u.lastLoginAt), + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + color: t.text)), + last: true), + const SizedBox(height: 16), + WriteGuard( + child: Row(children: [ + Expanded( + child: DsButton('重置密码', onPressed: () { + Navigator.of(ctx).pop(); + _promptResetPassword(u); + }), + ), + const SizedBox(width: 10), + Expanded( + child: DsButton(u.isActive ? '停用' : '启用', onPressed: () { + Navigator.of(ctx).pop(); + _toggleActive(u); + }), + ), + const SizedBox(width: 10), + Expanded( + child: DsButton('编辑', variant: DsBtnVariant.primary, + onPressed: () { + Navigator.of(ctx).pop(); + _openUser(u); + }), + ), + ]), + ), + ], + ); + }, + ); + } + + /// 重置密码(详情 sheet 入口;提示框与表单内「重置密码」同口径)。 + Future _promptResetPassword(AppUser u) async { + final ctrl = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('重置密码 · ${u.username}'), + content: SizedBox( + width: ctx.dialogWidth(320), + child: DsField('新密码', + input: TextField( + controller: ctrl, + obscureText: true, + decoration: const InputDecoration(hintText: '至少 8 位'), + )), + ), + actions: [ + DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)), + DsButton('重置', + variant: DsBtnVariant.primary, + onPressed: () => Navigator.of(ctx).pop(true)), + ], + ), + ); + if (ok != true || !mounted) return; + if (ctrl.text.length < 8) { + showDsToast(context, '密码至少 8 位'); + return; + } + try { + await ref.read(userListProvider.notifier).resetPassword(u.id, ctrl.text); + if (mounted) { + showDsToast(context, '密码已重置 ✓', bg: context.tokens.success); + } + } catch (e) { + if (mounted) { + showDsToast(context, '重置失败:$e', bg: context.tokens.danger); + } + } + } + Future _toggleActive(AppUser u) async { try { await ref @@ -376,6 +621,19 @@ class _UsersScreenState extends ConsumerState { } void _openUser([AppUser? user]) { + // 窄屏:表单走底部 sheet(原型 m-users FAB → 表单弹层) + if (context.isMobile) { + showMSheet( + context, + title: user == null ? '新增用户' : '编辑用户', + builder: (_) => _UserFormDialog( + user: user, + sheet: true, + onSaved: () => ref.read(userListProvider.notifier).reload(), + ), + ); + return; + } showAppDialog( context: context, builder: (ctx) => _UserFormDialog( @@ -387,10 +645,12 @@ class _UsersScreenState extends ConsumerState { } // ── 新增/编辑用户弹窗(原型 #ov:mgrid 2 列 + 角色 2×2 rcard + 状态 switch)── +// [sheet]=true 时以 sheet 内容形态渲染(无 Dialog 壳、标题由 showMSheet 提供)。 class _UserFormDialog extends ConsumerStatefulWidget { final AppUser? user; final VoidCallback onSaved; - const _UserFormDialog({this.user, required this.onSaved}); + final bool sheet; + const _UserFormDialog({this.user, required this.onSaved, this.sheet = false}); @override ConsumerState<_UserFormDialog> createState() => _UserFormDialogState(); @@ -507,118 +767,120 @@ class _UserFormDialogState extends ConsumerState<_UserFormDialog> { Widget build(BuildContext context) { final t = context.tokens; final isEdit = widget.user != null; + final form = Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // sheet 形态标题由 showMSheet 头部提供 + if (!widget.sheet) ...[ + Text(isEdit ? '编辑用户' : '新增用户', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 18), + ], + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Expanded( + child: DsField('姓名', + required: true, + input: TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(hintText: '例如 李采购'), + validator: (v) => + (v == null || v.trim().isEmpty) ? '不能为空' : null, + )), + ), + const SizedBox(width: 14), + Expanded( + child: DsField('登录账号', + required: true, + input: TextFormField( + controller: _userCtrl, + decoration: const InputDecoration(hintText: '字母 / 数字'), + validator: (v) => + (v == null || v.trim().isEmpty) ? '不能为空' : null, + )), + ), + ]), + if (!isEdit) ...[ + const SizedBox(height: 14), + DsField('初始密码', + required: true, + input: TextFormField( + controller: _passCtrl, + obscureText: true, + decoration: + const InputDecoration(hintText: '至少 8 位,成员首次登录可改'), + validator: (v) => + (v == null || v.length < 8) ? '至少 8 位' : null, + )), + ], + const SizedBox(height: 16), + Text('角色', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + const SizedBox(height: 8), + // 原型 .roles 2×2 单选卡 + Column(children: [ + for (var row = 0; row < 2; row++) + Padding( + padding: EdgeInsets.only(bottom: row == 0 ? 10 : 0), + child: Row(children: [ + for (var col = 0; col < 2; col++) ...[ + if (col > 0) const SizedBox(width: 10), + Expanded(child: _roleCard(_roles[row * 2 + col])), + ], + ]), + ), + ]), + const SizedBox(height: 16), + Row(children: [ + Text('账号状态', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + const SizedBox(width: 12), + Switch( + value: _active, + onChanged: (v) => setState(() => _active = v), + ), + Text(_active ? '启用' : '停用', + style: TextStyle(fontSize: AppDims.fsBody, color: t.text)), + ]), + const SizedBox(height: 20), + Row(children: [ + if (isEdit) + TextButton( + onPressed: _resetPassword, + child: const Text('重置密码'), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white)) // ds-ignore: primary 底白字加载圈 + : const Text('保存'), + ), + ]), + ], + ), + ), + ); + if (widget.sheet) return form; return Dialog( child: Container( width: context.dialogWidth(520), padding: const EdgeInsets.all(22), - child: Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(isEdit ? '编辑用户' : '新增用户', - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.w600)), - const SizedBox(height: 18), - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: DsField('姓名', - required: true, - input: TextFormField( - controller: _nameCtrl, - decoration: const InputDecoration(hintText: '例如 李采购'), - validator: (v) => - (v == null || v.trim().isEmpty) ? '不能为空' : null, - )), - ), - const SizedBox(width: 14), - Expanded( - child: DsField('登录账号', - required: true, - input: TextFormField( - controller: _userCtrl, - decoration: - const InputDecoration(hintText: '字母 / 数字'), - validator: (v) => - (v == null || v.trim().isEmpty) ? '不能为空' : null, - )), - ), - ]), - if (!isEdit) ...[ - const SizedBox(height: 14), - DsField('初始密码', - required: true, - input: TextFormField( - controller: _passCtrl, - obscureText: true, - decoration: - const InputDecoration(hintText: '至少 8 位,成员首次登录可改'), - validator: (v) => - (v == null || v.length < 8) ? '至少 8 位' : null, - )), - ], - const SizedBox(height: 16), - Text('角色', - style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), - const SizedBox(height: 8), - // 原型 .roles 2×2 单选卡 - Column(children: [ - for (var row = 0; row < 2; row++) - Padding( - padding: EdgeInsets.only(bottom: row == 0 ? 10 : 0), - child: Row(children: [ - for (var col = 0; col < 2; col++) ...[ - if (col > 0) const SizedBox(width: 10), - Expanded(child: _roleCard(_roles[row * 2 + col])), - ], - ]), - ), - ]), - const SizedBox(height: 16), - Row(children: [ - Text('账号状态', - style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), - const SizedBox(width: 12), - Switch( - value: _active, - onChanged: (v) => setState(() => _active = v), - ), - Text(_active ? '启用' : '停用', - style: - TextStyle(fontSize: AppDims.fsBody, color: t.text)), - ]), - const SizedBox(height: 20), - Row(children: [ - if (isEdit) - TextButton( - onPressed: _resetPassword, - child: const Text('重置密码'), - ), - const Spacer(), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('取消'), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: - Colors.white)) // ds-ignore: primary 底白字加载圈 - : const Text('保存'), - ), - ]), - ], - ), - ), - ), + child: form, ), ); } diff --git a/client/test/golden/about_golden_test.dart b/client/test/golden/about_golden_test.dart index c6657cf..e7d9e24 100644 --- a/client/test/golden/about_golden_test.dart +++ b/client/test/golden/about_golden_test.dart @@ -8,13 +8,13 @@ import 'package:jiu_client/providers/changelog_provider.dart'; import 'package:jiu_client/providers/license_provider.dart'; import 'package:jiu_client/screens/about/about_screen.dart'; -import '../support/golden_harness.dart'; import '../support/shell_harness.dart'; /// design-distill 阶段4:关于我们 golden × 三主题(回归闸 + 保真基准)。 /// Hero/产品信息/授权信息/更新日志 数据照抄原型 screens/about.html /// (授权 2027-03-15 · CHANGELOG 4 条,默认 2 条展示): /// node tools/fidelity.mjs about +/// 窄屏 hero+hub 形态另见 about_mobile_golden_test.dart。 /// 更新基准:flutter test --update-goldens test/golden/about_golden_test.dart class _FakeLicense extends LicenseNotifier { @@ -104,13 +104,4 @@ void main() { overrides: _overrides, logical: const Size(1280, 900), ); - - // 移动:窄屏单列 - goldenAcrossThemes( - 'about 整屏(移动)', - goldenPrefix: 'about_mobile', - child: () => const Scaffold(body: AboutScreen()), - overrides: _overrides, - logical: const Size(390, 1600), - ); } diff --git a/client/test/golden/about_mobile_golden_test.dart b/client/test/golden/about_mobile_golden_test.dart new file mode 100644 index 0000000..705634d --- /dev/null +++ b/client/test/golden/about_mobile_golden_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jiu_client/core/utils/clock.dart'; +import 'package:jiu_client/providers/changelog_provider.dart'; +import 'package:jiu_client/screens/about/about_screen.dart'; + +import '../support/golden_harness.dart'; + +/// 关于我们窄屏 golden × 三主题(390×844 @2x):品牌 hero(图标+名+版本+ +/// 检查更新+反馈双按钮)+ hub 链接组 + 产品信息卡 + 更新日志时间线 + 页脚, +/// 授权信息不显示(已迁 /me/license)——对齐原型 m-about.html。 +/// 更新基准:flutter test --update-goldens test/golden/about_mobile_golden_test.dart + +const _changelog = [ + ChangelogEntry(version: '1.0.72', date: '2026-06-22', sections: [ + ( + type: 'feat', + items: [ + '用户管理新增四级角色:超级管理员 / 管理员 / 操作员 / 只读', + '商品公开页编辑器:图片、品鉴笔记、适饮建议可视化编辑并实时预览', + ] + ), + ( + type: 'impr', + items: [ + '顶栏 / 侧栏 / 状态栏统一为单一来源,全局一处生效', + '金额卡片改用「万」单位展示,更符合阅读习惯', + ] + ), + ]), + ChangelogEntry(version: '1.0.66', date: '2026-06-18', sections: [ + ( + type: 'feat', + items: [ + '入库 / 出库列表新增搜索框,支持单号、商品、往来单位关键字检索', + '入库 / 出库 / 库存页新增一键刷新', + ] + ), + ( + type: 'fix', + items: [ + '入库审核库存沿用明细真实快照名', + '库存列表过滤软删商品,修复历史导入占位顶替', + ] + ), + ]), +]; + +List _overrides() => [ + changelogProvider.overrideWith((ref) async => _changelog), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + PackageInfo.setMockInitialValues( + appName: 'jiu', + packageName: 'com.yanmei.jiu', + version: '1.0.72', + buildNumber: '72', + buildSignature: '', + ); + appClock = () => DateTime(2026, 7, 13, 9, 30, 15); + + goldenAcrossThemes( + 'about 整屏(移动)', + goldenPrefix: 'about_mobile', + child: () => const Scaffold(body: AboutScreen()), + overrides: _overrides, + logical: const Size(390, 844), + ); +} diff --git a/client/test/golden/device_management_golden_test.dart b/client/test/golden/device_management_golden_test.dart index ae1fb10..d93598a 100644 --- a/client/test/golden/device_management_golden_test.dart +++ b/client/test/golden/device_management_golden_test.dart @@ -9,13 +9,13 @@ import 'package:jiu_client/providers/session_provider.dart'; import 'package:jiu_client/providers/shop_provider.dart'; import 'package:jiu_client/screens/devices/device_management_screen.dart'; -import '../support/golden_harness.dart'; import '../support/shell_harness.dart'; /// design-distill 阶段4:设备管理 golden × 三主题(回归闸 + 保真基准)。 /// 桌面版挂进真 AppShell,数据照抄原型 screens/devices.html 的 /// SESSIONS(4 行会话)与 DEVICES(5 台外设 → 店级 custom_fields.peripherals): /// node tools/fidelity.mjs devices +/// 窄屏卡片流另见 device_management_mobile_golden_test.dart。 /// 更新基准:flutter test --update-goldens test/golden/device_management_golden_test.dart // 原型 SESSIONS 4 行(时间 MM-DD HH:mm) @@ -155,13 +155,4 @@ void main() { overrides: _overrides, logical: const Size(1280, 900), ); - - // 移动:窄屏卡片流 - goldenAcrossThemes( - 'devices 整屏(移动)', - goldenPrefix: 'device_management_mobile', - child: () => const Scaffold(body: DeviceManagementScreen()), - overrides: _overrides, - logical: const Size(390, 1400), - ); } diff --git a/client/test/golden/device_management_mobile_golden_test.dart b/client/test/golden/device_management_mobile_golden_test.dart new file mode 100644 index 0000000..09879cc --- /dev/null +++ b/client/test/golden/device_management_mobile_golden_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/models/session.dart'; +import 'package:jiu_client/models/shop.dart'; +import 'package:jiu_client/providers/session_provider.dart'; +import 'package:jiu_client/providers/shop_provider.dart'; +import 'package:jiu_client/screens/devices/device_management_screen.dart'; + +import '../support/golden_harness.dart'; + +/// 设备管理窄屏 golden × 三主题(390×844 @2x):m-section 两区块—— +/// 登录设备会话卡流(用户·平台+本机 / 设备·IP / 在线徽章+时间 / ›) +/// + 外设卡流(名称 / 型号·类型·连接 / 状态徽章 / 最近活动脚注), +/// 「+ 添加设备」在区块标题行右侧,打印模板不渲染——对齐原型 m-devices.html。 +/// 更新基准:flutter test --update-goldens test/golden/device_management_mobile_golden_test.dart + +final _sessions = [ + DeviceSession( + id: 1, + userId: 1, + username: '王经理', + realName: '王经理', + platform: 'macos', + platformClass: 'desktop', + deviceName: 'macos', + ip: '192.168.1.20', + createdAt: DateTime(2026, 6, 20, 11, 23), + lastSeenAt: DateTime(2026, 6, 22, 17, 40), + online: true, + isCurrent: true), + DeviceSession( + id: 2, + userId: 1, + username: '王经理', + realName: '王经理', + platform: 'windows', + platformClass: 'desktop', + deviceName: 'windows', + ip: '192.168.1.21', + createdAt: DateTime(2026, 6, 20, 10, 47), + lastSeenAt: DateTime(2026, 6, 22, 17, 40), + online: true, + isCurrent: false), + DeviceSession( + id: 3, + userId: 2, + username: '李采购', + realName: '李采购', + platform: 'windows', + platformClass: 'desktop', + deviceName: 'windows', + ip: '192.168.1.35', + createdAt: DateTime(2026, 6, 20, 9, 13), + lastSeenAt: DateTime(2026, 6, 22, 17, 11), + online: false, + isCurrent: false), +]; + +const _peripherals = [ + { + 'name': '前台标签打印机', + 'kind': '标签打印机', + 'status': '在线', + 'model': 'Zebra GK888t', + 'conn': 'USB', + 'last': '2 分钟前' + }, + { + 'name': '收银台小票机', + 'kind': '小票打印机', + 'status': '在线', + 'model': '佳博 GP-58MB', + 'conn': '蓝牙', + 'last': '刚刚' + }, + { + 'name': '入库扫码枪', + 'kind': '扫码枪', + 'status': '离线', + 'model': 'Honeywell 1900', + 'conn': 'USB', + 'last': '5 分钟前' + }, +]; + +const _shopWithPeripherals = ShopInfo( + id: 1, + code: 'DSJH-001', + name: '鼎晟酒行', + address: '浙江省杭州市拱墅区运河路 168 号 1 幢', + phone: '0571-8888 6666', + managerName: '王经理', + wechatId: 'dingsheng-wine', + customFields: {'peripherals': _peripherals}, +); + +class _FakeSessionNotifier extends SessionListNotifier { + @override + Future> build() async => _sessions; + @override + Future reload() async {} +} + +List _overrides() => [ + sessionListProvider.overrideWith(() => _FakeSessionNotifier()), + shopInfoProvider.overrideWith((ref) => _shopWithPeripherals), + isReadonlyProvider.overrideWithValue(false), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + goldenAcrossThemes( + 'devices 卡片流 整屏(移动)', + goldenPrefix: 'device_management_mobile', + child: () => const Scaffold(body: DeviceManagementScreen()), + overrides: _overrides, + logical: const Size(390, 844), + ); +} diff --git a/client/test/golden/goldens/about_mobile_a.png b/client/test/golden/goldens/about_mobile_a.png index 024127b..7cd0b2c 100644 Binary files a/client/test/golden/goldens/about_mobile_a.png and b/client/test/golden/goldens/about_mobile_a.png differ diff --git a/client/test/golden/goldens/about_mobile_b.png b/client/test/golden/goldens/about_mobile_b.png index a450132..3337a38 100644 Binary files a/client/test/golden/goldens/about_mobile_b.png and b/client/test/golden/goldens/about_mobile_b.png differ diff --git a/client/test/golden/goldens/about_mobile_c.png b/client/test/golden/goldens/about_mobile_c.png index a329bb3..f47e565 100644 Binary files a/client/test/golden/goldens/about_mobile_c.png and b/client/test/golden/goldens/about_mobile_c.png differ diff --git a/client/test/golden/goldens/device_management_mobile_a.png b/client/test/golden/goldens/device_management_mobile_a.png index a5b8b28..f1d2313 100644 Binary files a/client/test/golden/goldens/device_management_mobile_a.png and b/client/test/golden/goldens/device_management_mobile_a.png differ diff --git a/client/test/golden/goldens/device_management_mobile_b.png b/client/test/golden/goldens/device_management_mobile_b.png index 93b34bd..243d1bc 100644 Binary files a/client/test/golden/goldens/device_management_mobile_b.png and b/client/test/golden/goldens/device_management_mobile_b.png differ diff --git a/client/test/golden/goldens/device_management_mobile_c.png b/client/test/golden/goldens/device_management_mobile_c.png index 184ca62..78df0fd 100644 Binary files a/client/test/golden/goldens/device_management_mobile_c.png and b/client/test/golden/goldens/device_management_mobile_c.png differ diff --git a/client/test/golden/goldens/settings_mobile_a.png b/client/test/golden/goldens/settings_mobile_a.png index 3610a36..52e2144 100644 Binary files a/client/test/golden/goldens/settings_mobile_a.png and b/client/test/golden/goldens/settings_mobile_a.png differ diff --git a/client/test/golden/goldens/settings_mobile_b.png b/client/test/golden/goldens/settings_mobile_b.png index 4c903b7..4e87c33 100644 Binary files a/client/test/golden/goldens/settings_mobile_b.png and b/client/test/golden/goldens/settings_mobile_b.png differ diff --git a/client/test/golden/goldens/settings_mobile_c.png b/client/test/golden/goldens/settings_mobile_c.png index fccb49b..073a7a0 100644 Binary files a/client/test/golden/goldens/settings_mobile_c.png and b/client/test/golden/goldens/settings_mobile_c.png differ diff --git a/client/test/golden/goldens/users_mobile_a.png b/client/test/golden/goldens/users_mobile_a.png index b774aaa..eb4d90b 100644 Binary files a/client/test/golden/goldens/users_mobile_a.png and b/client/test/golden/goldens/users_mobile_a.png differ diff --git a/client/test/golden/goldens/users_mobile_b.png b/client/test/golden/goldens/users_mobile_b.png index a682f48..6bc833a 100644 Binary files a/client/test/golden/goldens/users_mobile_b.png and b/client/test/golden/goldens/users_mobile_b.png differ diff --git a/client/test/golden/goldens/users_mobile_c.png b/client/test/golden/goldens/users_mobile_c.png index 888a602..b077798 100644 Binary files a/client/test/golden/goldens/users_mobile_c.png and b/client/test/golden/goldens/users_mobile_c.png differ diff --git a/client/test/golden/settings_golden_test.dart b/client/test/golden/settings_golden_test.dart index d03b51b..0f49bed 100644 --- a/client/test/golden/settings_golden_test.dart +++ b/client/test/golden/settings_golden_test.dart @@ -7,13 +7,13 @@ import 'package:jiu_client/models/shop.dart'; import 'package:jiu_client/providers/shop_provider.dart'; import 'package:jiu_client/screens/settings/settings_screen.dart'; -import '../support/golden_harness.dart'; import '../support/shell_harness.dart'; /// design-distill 阶段4:系统设置 golden × 三主题(回归闸 + 保真基准)。 /// 桌面版挂进真 AppShell,默认「门店信息」面板,门店数据照抄原型 /// screens/settings.html(鼎晟酒行 / DSJH-001 / 拱墅区运河路…): /// node tools/fidelity.mjs settings +/// 窄屏 hub 形态另见 settings_mobile_golden_test.dart。 /// 更新基准:flutter test --update-goldens test/golden/settings_golden_test.dart const _shop = ShopInfo( @@ -44,13 +44,4 @@ void main() { overrides: _overrides, logical: const Size(1280, 900), ); - - // 移动:seg 横滚 + 单列面板 - goldenAcrossThemes( - 'settings 门店信息 整屏(移动)', - goldenPrefix: 'settings_mobile', - child: () => const Scaffold(body: SettingsScreen()), - overrides: _overrides, - logical: const Size(390, 1200), - ); } diff --git a/client/test/golden/settings_mobile_golden_test.dart b/client/test/golden/settings_mobile_golden_test.dart new file mode 100644 index 0000000..a38a2d8 --- /dev/null +++ b/client/test/golden/settings_mobile_golden_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/models/shop.dart'; +import 'package:jiu_client/providers/shop_provider.dart'; +import 'package:jiu_client/screens/settings/settings_screen.dart'; + +import '../support/golden_harness.dart'; + +/// 系统设置窄屏 golden × 三主题(390×844 @2x):hub 形态三组 +/// 门店(门店信息/用户与角色/编号规则)+ 偏好(主题外观/默认仓库/设备管理) +/// + 数据(数据备份/关于与版本)——对齐原型 m-settings.html。 +/// 更新基准:flutter test --update-goldens test/golden/settings_mobile_golden_test.dart + +const _shop = ShopInfo( + id: 1, + code: 'DSJH-001', + name: '鼎晟酒行', + address: '浙江省杭州市拱墅区运河路 168 号 1 幢', + phone: '0571-8888 6666', + managerName: '王经理', + wechatId: 'dingsheng-wine', +); + +List _overrides() => [ + shopInfoProvider.overrideWith((ref) => _shop), + isReadonlyProvider.overrideWithValue(false), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + goldenAcrossThemes( + 'settings hub 整屏(移动)', + goldenPrefix: 'settings_mobile', + child: () => const Scaffold(body: SettingsScreen()), + overrides: _overrides, + logical: const Size(390, 844), + ); +} diff --git a/client/test/golden/users_golden_test.dart b/client/test/golden/users_golden_test.dart index 56652d1..3677713 100644 --- a/client/test/golden/users_golden_test.dart +++ b/client/test/golden/users_golden_test.dart @@ -8,13 +8,13 @@ import 'package:jiu_client/models/user.dart'; import 'package:jiu_client/providers/user_provider.dart'; import 'package:jiu_client/screens/settings/users_screen.dart'; -import '../support/golden_harness.dart'; import '../support/shell_harness.dart'; /// design-distill 阶段4:用户管理独立页 golden × 三主题(回归闸 + 保真基准)。 /// 数据照抄原型 screens/users.html 的 USERS(5 人,四级角色), /// 相对时间(今天/昨天)用可注入时钟 appClock 冻结在 2026-07-13 09:30: /// node tools/fidelity.mjs users +/// 窄屏卡片流另见 users_mobile_golden_test.dart。 /// 更新基准:flutter test --update-goldens test/golden/users_golden_test.dart // 原型 USERS 5 行;最近登录 今天09:12 / 今天08:50 / 昨天18:40 / 06-19 / 06-10 @@ -83,13 +83,4 @@ void main() { overrides: _overrides, logical: const Size(1280, 900), ); - - // 移动:窄屏卡片流 - goldenAcrossThemes( - 'users 整屏(移动)', - goldenPrefix: 'users_mobile', - child: () => const Scaffold(body: UsersScreen()), - overrides: _overrides, - logical: const Size(390, 1400), - ); } diff --git a/client/test/golden/users_mobile_golden_test.dart b/client/test/golden/users_mobile_golden_test.dart new file mode 100644 index 0000000..e0b4d05 --- /dev/null +++ b/client/test/golden/users_mobile_golden_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/core/utils/clock.dart'; +import 'package:jiu_client/models/user.dart'; +import 'package:jiu_client/providers/user_provider.dart'; +import 'package:jiu_client/screens/settings/users_screen.dart'; + +import '../support/golden_harness.dart'; + +/// 用户管理窄屏 golden × 三主题(390×844 @2x):m-section「成员 · 共 N」+ +/// 头像卡片流(40 圆标 + 姓名 + 账号 + 角色图标徽章)+ FAB 新增—— +/// 对齐原型 m-users.html。数据与桌面 golden 同源(5 人四级角色)。 +/// 更新基准:flutter test --update-goldens test/golden/users_mobile_golden_test.dart + +const _users = [ + AppUser( + id: 1, + username: 'wang', + realName: '王经理', + role: 'superadmin', + isActive: true, + lastLoginAt: '2026-07-13T09:12:00'), + AppUser( + id: 2, + username: 'zhang', + realName: '张主管', + role: 'admin', + isActive: true, + lastLoginAt: '2026-07-13T08:50:00'), + AppUser( + id: 3, + username: 'li', + realName: '李采购', + role: 'operator', + isActive: true, + lastLoginAt: '2026-07-12T18:40:00'), + AppUser( + id: 4, + username: 'zhao', + realName: '赵会计', + role: 'readonly', + isActive: true, + lastLoginAt: '2026-06-19T10:05:00'), + AppUser( + id: 5, + username: 'qian', + realName: '钱仓管', + role: 'operator', + isActive: false, + lastLoginAt: '2026-06-10T14:22:00'), +]; + +class _FakeUserList extends UserListNotifier { + @override + Future> build() async => _users; + @override + Future reload() async {} +} + +List _overrides() => [ + userListProvider.overrideWith(() => _FakeUserList()), + isReadonlyProvider.overrideWithValue(false), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + // 冻结相对日期基准(详情 sheet 的最近登录) + appClock = () => DateTime(2026, 7, 13, 9, 30, 15); + + goldenAcrossThemes( + 'users 卡片流 整屏(移动)', + goldenPrefix: 'users_mobile', + child: () => const UsersScreen(), + overrides: _overrides, + logical: const Size(390, 844), + ); +}