diff --git a/client/lib/screens/partners/partners_screen.dart b/client/lib/screens/partners/partners_screen.dart index 9208a8c..d2d2787 100644 --- a/client/lib/screens/partners/partners_screen.dart +++ b/client/lib/screens/partners/partners_screen.dart @@ -18,6 +18,8 @@ import '../../models/partner.dart'; import '../../providers/partner_provider.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_table.dart'; +import '../../widgets/ds/m_search_row.dart'; +import '../../widgets/ds/status_icon_map.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/partner_detail_drawer.dart'; import '../../widgets/write_guard.dart'; @@ -38,6 +40,9 @@ class _PartnersScreenState extends ConsumerState { String _typeChip = '全部'; static const _typeChips = ['全部', '供应商', '客户']; + // 窄屏对齐 m-partners 原型 F=['全部','供应商','客户','两者']; + // 「两者」后端无独立 type 值(FIND_IN_SET 语义),列表本就一页取全 → 客户端过滤。 + static const _typeChipsMobile = ['全部', '供应商', '客户', '两者']; @override void dispose() { @@ -89,41 +94,42 @@ class _PartnersScreenState extends ConsumerState { ), ), data: (result) { - final partners = result.data; final mobile = context.isMobile; - // 原型 .main{padding:22px 26px};窄屏保持紧凑 + // 「两者」为窄屏专属 chip:服务端 FIND_IN_SET 无独立值,客户端过滤 + final partners = _typeChip == '两者' + ? result.data.where((p) => p.typeLabel == '两者').toList() + : result.data; + // 原型 .main{padding:22px 26px};窄屏对齐 m-partners .m-scroll{padding:14px} return Container( color: t.bg, padding: mobile - ? EdgeInsets.zero + ? const EdgeInsets.fromLTRB(AppDims.sp3, AppDims.sp3, AppDims.sp3, 0) : const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Column( children: [ - // ── 头部(原型 .head{margin-bottom:18px})── - Container( - width: double.infinity, - color: t.bg, - padding: mobile - ? const EdgeInsets.fromLTRB( - AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2) - : const EdgeInsets.only(bottom: 18), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('往来单位', - style: TextStyle( - fontSize: AppDims.fsH1, - fontWeight: FontWeight.w700, - color: t.heading)), - const SizedBox(width: AppDims.sp3), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text('供应商与客户档案 · 共 ${result.total} 个', + // ── 头部(原型 .head{margin-bottom:18px});窄屏隐藏—— + // 标题由壳顶栏 .m-top data-title 提供(对齐 m-partners)── + if (!mobile) + Container( + width: double.infinity, + color: t.bg, + padding: const EdgeInsets.only(bottom: 18), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('往来单位', style: TextStyle( - fontSize: AppDims.fsSm, color: t.muted)), - ), - const Spacer(), - if (!mobile) ...[ + fontSize: AppDims.fsH1, + fontWeight: FontWeight.w700, + color: t.heading)), + const SizedBox(width: AppDims.sp3), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text('供应商与客户档案 · 共 ${result.total} 个', + style: TextStyle( + fontSize: AppDims.fsSm, color: t.muted)), + ), + const Spacer(), DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: _reload), const SizedBox(width: 10), @@ -134,9 +140,8 @@ class _PartnersScreenState extends ConsumerState { onPressed: () => _openForm()), ), ], - ], + ), ), - ), Expanded( child: DsTable( pagerInfoText: '共 ${result.total} 个往来单位', @@ -167,18 +172,61 @@ class _PartnersScreenState extends ConsumerState { ); } - // ── toolbar(原型:searchbox 260 + chips + sp + 重置)── + // ── toolbar(原型:searchbox 260 + chips + sp + 重置; + // 窄屏对齐 m-partners:.m-search 搜索行 + .m-pill 类型横排)── Widget _toolbar(bool mobile, List partners) { + void onSearch(String v) { + _debounce?.cancel(); + _debounce = Timer(AppConstants.searchDebounce, () { + ref.read(partnersScreenProvider.notifier).setKeyword(v.trim()); + }); + } + + if (mobile) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MSearchRow( + controller: _searchCtrl, + hint: '名称 / 拼音', + onChanged: onSearch, + ), + const SizedBox(height: AppDims.sp2), + Row(children: [ + // 类型 pills(原型 m-pill 的既有对应物 DsChip,selected 态) + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + for (final c in _typeChipsMobile) ...[ + DsChip( + label: c, + selected: _typeChip == c, + caret: false, + onTap: () => _setType(c)), + const SizedBox(width: 8), + ], + ]), + ), + ), + WriteGuard( + child: DsButton('新增', + small: true, + icon: LucideIcons.plus, + variant: DsBtnVariant.primary, + onPressed: () => _openForm()), + ), + ]), + ], + ); + } + final search = DsSearchBox( controller: _searchCtrl, hint: '名称 / 拼音', - width: mobile ? null : 260, - onChanged: (v) { - _debounce?.cancel(); - _debounce = Timer(AppConstants.searchDebounce, () { - ref.read(partnersScreenProvider.notifier).setKeyword(v.trim()); - }); - }, + width: 260, + onChanged: onSearch, ); final chips = [ for (final c in _typeChips) ...[ @@ -209,27 +257,6 @@ class _PartnersScreenState extends ConsumerState { .toList(), ); }); - if (mobile) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - search, - const SizedBox(height: AppDims.sp2), - Row(children: [ - ...chips, - const Spacer(), - WriteGuard( - child: DsButton('新增', - small: true, - icon: LucideIcons.plus, - variant: DsBtnVariant.primary, - onPressed: () => _openForm()), - ), - ]), - ], - ); - } return Row(children: [ search, const SizedBox(width: 10), @@ -312,13 +339,40 @@ class _PartnersScreenState extends ConsumerState { ); } - // ── 窄屏卡片(m-partners:右侧类型徽章 + 应收/应付)── + /// 类型徽章图标变体(原型 m-partners:badge b-供应商/客户/两者 + BADGE_ICON)。 + /// 色调与 partnerTypeBadge(partner_detail_drawer)一致,仅多前导图标。 + DsBadge _typeBadgeIcon(Partner p) => DsBadge(p.typeLabel, + tone: switch (p.typeLabel) { + '两者' => DsBadgeTone.warn, + '客户' => DsBadgeTone.accent, + _ => DsBadgeTone.info, + }, + icon: statusIcon(p.typeLabel)); + + /// 状态徽章图标变体(原型 badge b-启用/b-停用)。 + DsBadge _statusBadgeIcon(Partner p) { + final label = p.status == 'disabled' ? '停用' : '启用'; + return DsBadge(label, + tone: label == '启用' ? DsBadgeTone.ok : DsBadgeTone.muted, + icon: statusIcon(label)); + } + + // ── 窄屏卡片(m-partners:mc-sub mono 编码 + 右侧类型/状态图标徽章)── Widget _card(Partner p, Map bal) { final b = bal[p.id]; return MobileListCard( title: Text(p.name), - subtitle: (p.code?.isNotEmpty == true) ? Text(p.code!) : null, - trailing: partnerTypeBadge(p), + subtitle: (p.code?.isNotEmpty == true) + ? Text(p.code!, + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback)) + : null, + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + _typeBadgeIcon(p), + const SizedBox(width: 6), + _statusBadgeIcon(p), + ]), fields: [ if (p.contact?.isNotEmpty == true) MobileCardField('联系人', p.contact), if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone), diff --git a/client/lib/screens/products/product_detail_screen.dart b/client/lib/screens/products/product_detail_screen.dart index 30ccbef..27116ee 100644 --- a/client/lib/screens/products/product_detail_screen.dart +++ b/client/lib/screens/products/product_detail_screen.dart @@ -291,7 +291,8 @@ class _ProductDetailScreenState extends ConsumerState { } final p = _product!; return SingleChildScrollView( - padding: const EdgeInsets.all(24), + // 桌面 24;窄屏收紧到 sp4,避免内容区过窄 + padding: EdgeInsets.all(context.isMobile ? AppDims.sp4 : 24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -579,29 +580,31 @@ class _ProductDetailScreenState extends ConsumerState { fontWeight: FontWeight.w600, color: context.tokens.muted)), const SizedBox(height: 8), - SizedBox( - height: 120, - child: Row( - children: [ - ...p.images.asMap().entries.map((e) => _ImageThumbnail( - url: AppConfig.baseUrl + e.value.url, - onDelete: () => _deleteImage(e.value), - onView: () => showFullscreenImages( - context, - p.images - .map((img) => AppConfig.baseUrl + img.url) - .toList(), - initialIndex: e.key, - ), - )), - if (p.images.length < 5) - WriteGuard( - child: _UploadButton( - uploading: _uploading, onTap: _pickAndUpload), - ), - ], - ), - ), + Builder(builder: (context) { + final children = [ + ...p.images.asMap().entries.map((e) => _ImageThumbnail( + url: AppConfig.baseUrl + e.value.url, + onDelete: () => _deleteImage(e.value), + onView: () => showFullscreenImages( + context, + p.images.map((img) => AppConfig.baseUrl + img.url).toList(), + initialIndex: e.key, + ), + )), + if (p.images.length < 5) + WriteGuard( + child: + _UploadButton(uploading: _uploading, onTap: _pickAndUpload), + ), + ]; + return SizedBox( + height: 120, + // 窄屏:缩略图横向滚动(多图时 Row 会溢出 390 视口) + child: context.isMobile + ? ListView(scrollDirection: Axis.horizontal, children: children) + : Row(children: children), + ); + }), if (p.images.isEmpty && !_uploading) Padding( padding: const EdgeInsets.only(top: 4), @@ -630,6 +633,34 @@ class _ProductDetailScreenState extends ConsumerState { ), ); + // (行组件, 桌面 Wrap 内的固定宽);窄屏转单列——行占满整宽,不再 200px 截断 + final entries = <(Widget, double)>[ + (row('编号', p.code.isEmpty ? '-' : p.code), 200), + (row('系列', p.series?.isEmpty ?? true ? '-' : p.series!), 200), + (row('规格', p.spec?.isEmpty ?? true ? '-' : p.spec!), 200), + (row('品牌', p.brand?.isEmpty ?? true ? '-' : p.brand!), 200), + (row('单位', p.unit.isEmpty ? '-' : p.unit), 200), + if (p.categoryName?.isNotEmpty ?? false) (row('分类', p.categoryName!), 200), + ( + row( + '进价', + p.purchasePrice != null && p.purchasePrice! > 0 + ? '¥${p.purchasePrice!.toStringAsFixed(2)}' + : '-'), + 200 + ), + if (p.minStock != null && p.minStock! > 0) + (row('库存预警', '${p.minStock}'), 200), + if (p.originName?.isNotEmpty ?? false) (row('产地', p.originName!), 200), + if (p.shelfLifeName?.isNotEmpty ?? false) + (row('保质期', p.shelfLifeName!), 200), + if (p.storageName?.isNotEmpty ?? false) + (row('储存方式', p.storageName!), 200), + if (p.barcode?.isNotEmpty ?? false) (row('条码', p.barcode!), 200), + if (p.remark?.isNotEmpty ?? false) (row('备注', p.remark!), 400), + ..._buildCustomFieldRows(p, row), + ]; + return Card( elevation: 0, shape: RoundedRectangleBorder( @@ -638,58 +669,29 @@ class _ProductDetailScreenState extends ConsumerState { ), child: Padding( padding: const EdgeInsets.all(16), - child: Wrap( - spacing: 32, - runSpacing: 0, - children: [ - SizedBox( - width: 200, child: row('编号', p.code.isEmpty ? '-' : p.code)), - SizedBox( - width: 200, - child: row('系列', p.series?.isEmpty ?? true ? '-' : p.series!)), - SizedBox( - width: 200, - child: row('规格', p.spec?.isEmpty ?? true ? '-' : p.spec!)), - SizedBox( - width: 200, - child: row('品牌', p.brand?.isEmpty ?? true ? '-' : p.brand!)), - SizedBox( - width: 200, child: row('单位', p.unit.isEmpty ? '-' : p.unit)), - if (p.categoryName?.isNotEmpty ?? false) - SizedBox(width: 200, child: row('分类', p.categoryName!)), - SizedBox( - width: 200, - child: row( - '进价', - p.purchasePrice != null && p.purchasePrice! > 0 - ? '¥${p.purchasePrice!.toStringAsFixed(2)}' - : '-')), - if (p.minStock != null && p.minStock! > 0) - SizedBox(width: 200, child: row('库存预警', '${p.minStock}')), - if (p.originName?.isNotEmpty ?? false) - SizedBox(width: 200, child: row('产地', p.originName!)), - if (p.shelfLifeName?.isNotEmpty ?? false) - SizedBox(width: 200, child: row('保质期', p.shelfLifeName!)), - if (p.storageName?.isNotEmpty ?? false) - SizedBox(width: 200, child: row('储存方式', p.storageName!)), - if (p.barcode?.isNotEmpty ?? false) - SizedBox(width: 200, child: row('条码', p.barcode!)), - if (p.remark?.isNotEmpty ?? false) - SizedBox(width: 400, child: row('备注', p.remark!)), - ...?_buildCustomFieldRows(p, row), - ], - ), + child: context.isMobile + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [for (final e in entries) e.$1], + ) + : Wrap( + spacing: 32, + runSpacing: 0, + children: [ + for (final e in entries) SizedBox(width: e.$2, child: e.$1), + ], + ), ), ); } - List? _buildCustomFieldRows( + List<(Widget, double)> _buildCustomFieldRows( Product p, Widget Function(String, String) row) { final fields = p.customFields; - if (fields == null || fields.isEmpty) return null; + if (fields == null || fields.isEmpty) return const []; return fields.entries .where((e) => e.value != null && e.value.toString().isNotEmpty) - .map((e) => SizedBox(width: 200, child: row(e.key, e.value.toString()))) + .map<(Widget, double)>((e) => (row(e.key, e.value.toString()), 200)) .toList(); } @@ -999,7 +1001,7 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { return AlertDialog( title: const Text('商品二维码'), content: SizedBox( - width: 220, + width: context.dialogWidth(220), height: 220, child: FutureBuilder( future: _future, diff --git a/client/lib/screens/products/products_screen.dart b/client/lib/screens/products/products_screen.dart index 4631323..3547ace 100644 --- a/client/lib/screens/products/products_screen.dart +++ b/client/lib/screens/products/products_screen.dart @@ -24,6 +24,8 @@ import '../../providers/shop_provider.dart'; import '../../providers/warehouse_provider.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_table.dart'; +import '../../widgets/ds/m_search_row.dart'; +import '../../widgets/ds/m_sheet.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/product_editor_drawer.dart'; import '../../widgets/write_guard.dart'; @@ -117,37 +119,37 @@ class _ProductsScreenState extends ConsumerState { final t = context.tokens; final mobile = context.isMobile; final addAction = _addAction; - // 原型 .main{padding:22px 26px};窄屏保持紧凑 + // 原型 .main{padding:22px 26px};窄屏对齐 m-products .m-scroll{padding:14px} return Container( color: t.bg, - padding: - mobile ? EdgeInsets.zero : const EdgeInsets.fromLTRB(26, 22, 26, 22), + padding: mobile + ? const EdgeInsets.fromLTRB(AppDims.sp3, AppDims.sp3, AppDims.sp3, 0) + : const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // ── 头部(原型 .head{margin-bottom:18px})── - Container( - color: t.bg, - padding: mobile - ? const EdgeInsets.fromLTRB( - AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2) - : const EdgeInsets.only(bottom: 18), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('基础数据', - style: TextStyle( - fontSize: AppDims.fsH1, - fontWeight: FontWeight.w700, - color: t.heading)), - const SizedBox(width: AppDims.sp3), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text(_subText(), - style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), - ), - const Spacer(), - if (!mobile) ...[ + // ── 头部(原型 .head{margin-bottom:18px});窄屏隐藏—— + // 标题由壳顶栏 .m-top data-title 提供(对齐 m-products)── + if (!mobile) + Container( + color: t.bg, + padding: const EdgeInsets.only(bottom: 18), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('基础数据', + style: TextStyle( + fontSize: AppDims.fsH1, + fontWeight: FontWeight.w700, + color: t.heading)), + const SizedBox(width: AppDims.sp3), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text(_subText(), + style: + TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + ), + const Spacer(), DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: _reload), if (addAction != null) ...[ @@ -160,13 +162,12 @@ class _ProductsScreenState extends ConsumerState { ), ], ], - ], + ), ), - ), - // ── .seg(原型 margin-bottom:14px)── + // ── .seg(原型 margin-bottom:14px;窄屏横向可滚)── Padding( padding: mobile - ? const EdgeInsets.fromLTRB(AppDims.sp4, 0, AppDims.sp4, 12) + ? const EdgeInsets.only(bottom: AppDims.sp3) : const EdgeInsets.only(bottom: 14), child: Align( alignment: Alignment.centerLeft, @@ -245,39 +246,38 @@ class _ProductsScreenState extends ConsumerState { bool mobileAdd = false, }) { final mobile = context.isMobile; + if (mobile) { + // 窄屏对齐 m-products:.m-search 搜索行(自带清空 ×,重置并入)+ 新增 + final addAction = _addAction; + return Row(children: [ + Expanded( + child: MSearchRow( + controller: ctrl, + hint: hint, + onChanged: (_) => onChanged(), + ), + ), + if (mobileAdd && addAction != null) ...[ + const SizedBox(width: AppDims.sp2), + WriteGuard( + child: DsButton(_addLabel, + icon: LucideIcons.plus, + variant: DsBtnVariant.primary, + onPressed: addAction), + ), + ], + ]); + } final search = DsSearchBox( controller: ctrl, hint: hint, - width: mobile ? null : 260, + width: 260, onChanged: (_) => onChanged(), ); final reset = DsButton('重置', small: true, onPressed: () { ctrl.clear(); onChanged(); }); - if (mobile) { - final addAction = _addAction; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - search, - const SizedBox(height: AppDims.sp2), - Row(children: [ - reset, - const Spacer(), - if (mobileAdd && addAction != null) - WriteGuard( - child: DsButton(_addLabel, - small: true, - icon: LucideIcons.plus, - variant: DsBtnVariant.primary, - onPressed: addAction), - ), - ]), - ], - ); - } return Row(children: [ search, const Spacer(), @@ -375,7 +375,13 @@ class _ProductsScreenState extends ConsumerState { onTap: () => showProductEditorDrawer(context, productId: p.id, cost: p.purchasePrice), title: Text(p.name), - subtitle: p.code.isEmpty ? null : Text(p.code), + // 原型 m-products mc-sub:mono 编码 + subtitle: p.code.isEmpty + ? null + : Text(p.code, + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback)), fields: [ if ((p.series ?? '').isNotEmpty) MobileCardField('系列', p.series), @@ -692,7 +698,13 @@ class _ProductsScreenState extends ConsumerState { mobileCards: filtered .map((r) => MobileListCard( title: Text(r.name), - subtitle: (r.code ?? '').isEmpty ? null : Text(r.code!), + // 原型 m-products mc-sub:mono 编码 + subtitle: (r.code ?? '').isEmpty + ? null + : Text(r.code!, + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback)), trailing: r.isDefault ? const DsBadge('默认', tone: DsBadgeTone.info) : null, @@ -898,6 +910,91 @@ class _ProductsScreenState extends ConsumerState { TextEditingController(text: initial?['remark'] as String? ?? ''); final formKey = GlobalKey(); + List fields() => [ + DsField('名称', + required: true, + input: TextFormField( + controller: nameCtrl, + decoration: const InputDecoration(hintText: '名称'), + validator: (v) => + (v == null || v.trim().isEmpty) ? '请输入名称' : null, + )), + if (hasQuantity) ...[ + const SizedBox(height: 12), + DsField('装箱数量', + input: TextFormField( + controller: quantityCtrl, + decoration: const InputDecoration(hintText: '如 6'), + keyboardType: TextInputType.number, + )), + ], + const SizedBox(height: 12), + DsField('编号(可选)', + input: TextFormField( + controller: codeCtrl, + decoration: const InputDecoration(hintText: '编号'), + )), + const SizedBox(height: 12), + DsField('说明(选填)', + input: TextFormField( + controller: remarkCtrl, + decoration: + const InputDecoration(hintText: '备注说明,如适用品牌 / 用途'), + maxLines: 2, + )), + ]; + + Future submit(BuildContext ctx) async { + if (!formKey.currentState!.validate()) return; + final data = { + 'name': nameCtrl.text.trim(), + if (codeCtrl.text.isNotEmpty) 'code': codeCtrl.text.trim(), + if (hasQuantity && quantityCtrl.text.isNotEmpty) + 'quantity': int.tryParse(quantityCtrl.text) ?? 0, + if (remarkCtrl.text.isNotEmpty) 'remark': remarkCtrl.text.trim(), + }; + Navigator.of(ctx).pop(); + try { + await onSave(data); + } catch (e) { + if (mounted) { + showDsToast(context, '保存失败:$e', bg: context.tokens.danger); + } + } + } + + // 窄屏:底部 sheet 轻表单(原型 m-products openDictEdit:m-form + 按钮行) + if (context.isMobile) { + await showMSheet( + context, + title: title, + builder: (sctx) => Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...fields(), + const SizedBox(height: AppDims.sp4), + Row(children: [ + Expanded( + child: DsButton('取消', + onPressed: () => Navigator.of(sctx).pop()), + ), + const SizedBox(width: 10), + Expanded( + child: DsButton('保存', + variant: DsBtnVariant.primary, + onPressed: () => submit(sctx)), + ), + ]), + ], + ), + ), + ); + return; + } + await showAppDialog( context: context, builder: (ctx) => AlertDialog( @@ -908,62 +1005,14 @@ class _ProductsScreenState extends ConsumerState { key: formKey, child: Column( mainAxisSize: MainAxisSize.min, - children: [ - DsField('名称', - required: true, - input: TextFormField( - controller: nameCtrl, - decoration: const InputDecoration(hintText: '名称'), - validator: (v) => - (v == null || v.trim().isEmpty) ? '请输入名称' : null, - )), - if (hasQuantity) ...[ - const SizedBox(height: 12), - DsField('装箱数量', - input: TextFormField( - controller: quantityCtrl, - decoration: const InputDecoration(hintText: '如 6'), - keyboardType: TextInputType.number, - )), - ], - const SizedBox(height: 12), - DsField('编号(可选)', - input: TextFormField( - controller: codeCtrl, - decoration: const InputDecoration(hintText: '编号'), - )), - const SizedBox(height: 12), - DsField('说明(选填)', - input: TextFormField( - controller: remarkCtrl, - decoration: - const InputDecoration(hintText: '备注说明,如适用品牌 / 用途'), - maxLines: 2, - )), - ], + children: fields(), ), ), ), actions: [ DsButton('取消', onPressed: () => Navigator.of(ctx).pop()), - DsButton('保存', variant: DsBtnVariant.primary, onPressed: () async { - if (!formKey.currentState!.validate()) return; - final data = { - 'name': nameCtrl.text.trim(), - if (codeCtrl.text.isNotEmpty) 'code': codeCtrl.text.trim(), - if (hasQuantity && quantityCtrl.text.isNotEmpty) - 'quantity': int.tryParse(quantityCtrl.text) ?? 0, - if (remarkCtrl.text.isNotEmpty) 'remark': remarkCtrl.text.trim(), - }; - Navigator.of(ctx).pop(); - try { - await onSave(data); - } catch (e) { - if (mounted) { - showDsToast(context, '保存失败:$e', bg: context.tokens.danger); - } - } - }), + DsButton('保存', + variant: DsBtnVariant.primary, onPressed: () => submit(ctx)), ], ), ); @@ -1048,6 +1097,19 @@ class _ProductsScreenState extends ConsumerState { } void _showWarehouseDialog({Warehouse? warehouse}) { + // 窄屏:底部 sheet 轻表单(与系列/规格字典同形态) + if (context.isMobile) { + showMSheet( + context, + title: warehouse != null ? '编辑仓库' : '新增仓库', + builder: (_) => _WarehouseFormDialog( + warehouse: warehouse, + sheet: true, + onSaved: () => ref.read(warehouseListProvider.notifier).reload(), + ), + ); + return; + } showAppDialog( context: context, builder: (ctx) => _WarehouseFormDialog( @@ -1084,7 +1146,11 @@ class _WarehouseFormDialog extends ConsumerStatefulWidget { final Warehouse? warehouse; final VoidCallback onSaved; - const _WarehouseFormDialog({this.warehouse, required this.onSaved}); + /// true → 作为移动底部 sheet 的内容体渲染(无 AlertDialog 壳,按钮行内置)。 + final bool sheet; + + const _WarehouseFormDialog( + {this.warehouse, required this.onSaved, this.sheet = false}); @override ConsumerState<_WarehouseFormDialog> createState() => @@ -1145,8 +1211,57 @@ class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> { } } + List _fields() => [ + DsField('仓库名称', + required: true, + input: TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(hintText: '如:主仓'), + validator: (v) => (v == null || v.isEmpty) ? '不能为空' : null, + )), + const SizedBox(height: 12), + DsField('位置', + input: TextFormField( + controller: _locationCtrl, + decoration: const InputDecoration(hintText: '如:一层西侧'), + )), + const SizedBox(height: 12), + CheckboxListTile( + title: const Text('设为默认仓库'), + value: _isDefault, + onChanged: (v) => setState(() => _isDefault = v ?? false), + contentPadding: EdgeInsets.zero, + ), + ]; + @override Widget build(BuildContext context) { + // 窄屏 sheet 内容体(原型 m-form + 底部按钮行) + if (widget.sheet) { + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ..._fields(), + const SizedBox(height: AppDims.sp4), + Row(children: [ + Expanded( + child: + DsButton('取消', onPressed: () => Navigator.of(context).pop()), + ), + const SizedBox(width: 10), + Expanded( + child: DsButton(_saving ? '保存中…' : '保存', + variant: DsBtnVariant.primary, + onPressed: _saving ? null : _save), + ), + ]), + ], + ), + ); + } return AlertDialog( title: Text(widget.warehouse != null ? '编辑仓库' : '新增仓库'), content: SizedBox( @@ -1155,28 +1270,7 @@ class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> { key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, - children: [ - DsField('仓库名称', - required: true, - input: TextFormField( - controller: _nameCtrl, - decoration: const InputDecoration(hintText: '如:主仓'), - validator: (v) => (v == null || v.isEmpty) ? '不能为空' : null, - )), - const SizedBox(height: 12), - DsField('位置', - input: TextFormField( - controller: _locationCtrl, - decoration: const InputDecoration(hintText: '如:一层西侧'), - )), - const SizedBox(height: 12), - CheckboxListTile( - title: const Text('设为默认仓库'), - value: _isDefault, - onChanged: (v) => setState(() => _isDefault = v ?? false), - contentPadding: EdgeInsets.zero, - ), - ], + children: _fields(), ), ), ), diff --git a/client/test/golden/goldens/m_partners_a.png b/client/test/golden/goldens/m_partners_a.png new file mode 100644 index 0000000..fb42549 Binary files /dev/null and b/client/test/golden/goldens/m_partners_a.png differ diff --git a/client/test/golden/goldens/m_partners_b.png b/client/test/golden/goldens/m_partners_b.png new file mode 100644 index 0000000..e56453a Binary files /dev/null and b/client/test/golden/goldens/m_partners_b.png differ diff --git a/client/test/golden/goldens/m_partners_c.png b/client/test/golden/goldens/m_partners_c.png new file mode 100644 index 0000000..53b4a5b Binary files /dev/null and b/client/test/golden/goldens/m_partners_c.png differ diff --git a/client/test/golden/goldens/m_products_a.png b/client/test/golden/goldens/m_products_a.png new file mode 100644 index 0000000..223f75f Binary files /dev/null and b/client/test/golden/goldens/m_products_a.png differ diff --git a/client/test/golden/goldens/m_products_b.png b/client/test/golden/goldens/m_products_b.png new file mode 100644 index 0000000..681f937 Binary files /dev/null and b/client/test/golden/goldens/m_products_b.png differ diff --git a/client/test/golden/goldens/m_products_c.png b/client/test/golden/goldens/m_products_c.png new file mode 100644 index 0000000..f6e2714 Binary files /dev/null and b/client/test/golden/goldens/m_products_c.png differ diff --git a/client/test/golden/goldens/m_products_series_a.png b/client/test/golden/goldens/m_products_series_a.png new file mode 100644 index 0000000..2c30797 Binary files /dev/null and b/client/test/golden/goldens/m_products_series_a.png differ diff --git a/client/test/golden/goldens/m_products_series_b.png b/client/test/golden/goldens/m_products_series_b.png new file mode 100644 index 0000000..3d87f84 Binary files /dev/null and b/client/test/golden/goldens/m_products_series_b.png differ diff --git a/client/test/golden/goldens/m_products_series_c.png b/client/test/golden/goldens/m_products_series_c.png new file mode 100644 index 0000000..5fa0b1c Binary files /dev/null and b/client/test/golden/goldens/m_products_series_c.png differ diff --git a/client/test/golden/goldens/partners_mobile_a.png b/client/test/golden/goldens/partners_mobile_a.png index 4746621..0f2c5e2 100644 Binary files a/client/test/golden/goldens/partners_mobile_a.png and b/client/test/golden/goldens/partners_mobile_a.png differ diff --git a/client/test/golden/goldens/partners_mobile_b.png b/client/test/golden/goldens/partners_mobile_b.png index 40761ab..1fd7005 100644 Binary files a/client/test/golden/goldens/partners_mobile_b.png and b/client/test/golden/goldens/partners_mobile_b.png differ diff --git a/client/test/golden/goldens/partners_mobile_c.png b/client/test/golden/goldens/partners_mobile_c.png index 19523d8..98640b8 100644 Binary files a/client/test/golden/goldens/partners_mobile_c.png and b/client/test/golden/goldens/partners_mobile_c.png differ diff --git a/client/test/golden/goldens/products_archive_mobile_a.png b/client/test/golden/goldens/products_archive_mobile_a.png index 01d92af..443e215 100644 Binary files a/client/test/golden/goldens/products_archive_mobile_a.png and b/client/test/golden/goldens/products_archive_mobile_a.png differ diff --git a/client/test/golden/goldens/products_archive_mobile_b.png b/client/test/golden/goldens/products_archive_mobile_b.png index 002febb..067a575 100644 Binary files a/client/test/golden/goldens/products_archive_mobile_b.png and b/client/test/golden/goldens/products_archive_mobile_b.png differ diff --git a/client/test/golden/goldens/products_archive_mobile_c.png b/client/test/golden/goldens/products_archive_mobile_c.png index ce22f63..6d31dba 100644 Binary files a/client/test/golden/goldens/products_archive_mobile_c.png and b/client/test/golden/goldens/products_archive_mobile_c.png differ diff --git a/client/test/golden/partners_mobile_golden_test.dart b/client/test/golden/partners_mobile_golden_test.dart new file mode 100644 index 0000000..03b316c --- /dev/null +++ b/client/test/golden/partners_mobile_golden_test.dart @@ -0,0 +1,109 @@ +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/models/page_result.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/models/partner.dart'; +import 'package:jiu_client/providers/partner_provider.dart'; +import 'package:jiu_client/screens/partners/partners_screen.dart'; + +import '../support/golden_harness.dart'; + +/// 往来单位窄屏 golden × 三主题(390×844 @2x,对齐移动原型 m-partners.html): +/// 页内大标题隐藏(标题在壳顶栏)+ MSearchRow 搜索行 + 类型 pills +/// (全部/供应商/客户/两者)+ 卡片流(类型/状态图标徽章、mono 编码副行)。 +/// 更新基准:flutter test --update-goldens test/golden/partners_mobile_golden_test.dart + +// 行数据对齐 m-partners.html 的 PARTNERS(供应商/客户/两者、启用/停用齐备)。 +const _partners = [ + Partner( + id: 1, + code: 'GYS001', + name: '鼎晟供应链', + type: 'supplier', + contact: '张伟', + phone: '138-0011-2233'), + Partner( + id: 2, + code: 'KH002', + name: '城东烟酒行', + type: 'customer', + contact: '李建国', + phone: '139-2200-7788'), + Partner( + id: 3, + code: 'GYS003', + name: '贵州茅台经销', + type: 'supplier', + contact: '王芳', + phone: '137-6655-4321'), + Partner( + id: 4, + code: 'KH004', + name: '金樽会所', + type: 'customer', + contact: '陈静', + phone: '136-2211-9900'), + Partner( + id: 5, + code: 'KH005', + name: '喜宴婚庆', + type: 'customer', + contact: '周丽', + phone: '139-3344-5566', + status: 'disabled'), + Partner( + id: 6, + code: 'GYS006', + name: '川酒集团批发', + type: 'supplier,customer', + contact: '赵明', + phone: '138-7766-5544'), +]; + +// 原型 bal(应收/应付) +const _balances = { + 1: (recv: 0, pay: 52800), + 2: (recv: 86200, pay: 0), + 3: (recv: 0, pay: 0), + 4: (recv: 41000, pay: 0), + 5: (recv: 0, pay: 0), + 6: (recv: 0, pay: 0), +}; + +class _FakePartnersNotifier extends PartnerListNotifier { + @override + Future> build() async => + const PageResult(data: _partners, total: 6, page: 1, pageSize: 1000); + @override + void setPage(int page) {} + @override + void setPageSize(int pageSize) {} + @override + void setKeyword(String keyword) {} + @override + void setType(String? t) {} + @override + void reload() {} +} + +List _overrides() => [ + partnersScreenProvider.overrideWith(() => _FakePartnersNotifier()), + partnerBalancesProvider.overrideWith((ref) async => _balances), + isReadonlyProvider.overrideWithValue(false), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + goldenAcrossThemes( + 'partners 窄屏(手机视口)', + goldenPrefix: 'm_partners', + child: () => const Scaffold(body: PartnersScreen()), + overrides: _overrides, + logical: const Size(390, 844), + dpr: 2, + ); +} diff --git a/client/test/golden/products_mobile_golden_test.dart b/client/test/golden/products_mobile_golden_test.dart new file mode 100644 index 0000000..0108454 --- /dev/null +++ b/client/test/golden/products_mobile_golden_test.dart @@ -0,0 +1,146 @@ +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/models/page_result.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/models/product.dart'; +import 'package:jiu_client/models/product_option.dart'; +import 'package:jiu_client/models/shop.dart'; +import 'package:jiu_client/providers/product_option_provider.dart'; +import 'package:jiu_client/providers/product_provider.dart'; +import 'package:jiu_client/providers/shop_provider.dart'; +import 'package:jiu_client/screens/products/products_screen.dart'; + +import '../support/golden_harness.dart'; + +/// 基础数据窄屏 golden × 三主题(390×844 @2x,对齐移动原型 m-products.html): +/// 页内大标题隐藏 + 分段 tab 横滚(商品档案/介绍/系列/规格/仓库)+ +/// MSearchRow 搜索行 + 卡片流(mono 编码副行)。两组:商品档案 tab / 系列字典 tab。 +/// 更新基准:flutter test --update-goldens test/golden/products_mobile_golden_test.dart + +// 行数据照抄 m-products.html 的 PRODUCTS(前 6 行足够填满手机视口)。 +const _products = [ + Product( + id: 1, + code: 'MT-FT-500', + name: '茅台 飞天', + unit: '件', + series: '飞天', + spec: '500ml×6 · 53°', + purchasePrice: 2680), + Product( + id: 2, + code: 'MT-FT-100', + name: '茅台 飞天', + unit: '件', + series: '飞天', + spec: '100ml×24 · 53°', + purchasePrice: 620), + Product( + id: 3, + code: 'WLY-PW-500', + name: '五粮液 普五', + unit: '件', + series: '第八代', + spec: '500ml×6 · 52°', + purchasePrice: 1050), + Product( + id: 4, + code: 'GJ-1573-500', + name: '国窖 1573', + unit: '件', + series: '经典装', + spec: '500ml×6 · 52°', + purchasePrice: 980), + Product( + id: 5, + code: 'JNC-SJ-500', + name: '剑南春 水晶剑', + unit: '件', + series: '水晶剑', + spec: '500ml×6 · 52°', + purchasePrice: 438), + Product( + id: 6, + code: 'YH-M6-500', + name: '洋河 梦之蓝', + unit: '件', + series: 'M6+', + spec: '550ml×6 · 45°', + purchasePrice: 620), +]; + +// 系列字典(m-products DICTS.series)。 +const _series = [ + ProductSeriesOption(id: 1, code: 'XL001', name: '飞天', remark: '茅台主力系列'), + ProductSeriesOption(id: 2, code: 'XL002', name: '第八代', remark: '五粮液当前主力'), + ProductSeriesOption(id: 3, code: 'XL003', name: '经典装', remark: '国窖 1573 经典'), + ProductSeriesOption(id: 4, code: 'XL004', name: '水晶剑', remark: '剑南春核心'), + ProductSeriesOption(id: 5, code: 'XL005', name: 'M6+', remark: '梦之蓝高端'), + ProductSeriesOption(id: 6, code: 'XL006', name: '青花20', remark: '汾酒青花系列'), +]; + +const _shop = ShopInfo( + id: 1, + code: 'DSJH-001', + name: '鼎晟酒行', + address: '浙江省杭州市', + phone: '0571-88886666', + managerName: '王经理', + customFields: {'default_series_id': 1}); + +class _FakeProductList extends ProductListNotifier { + @override + Future> build() async => + const PageResult(data: _products, total: 6, page: 1, pageSize: 20); + @override + void setPage(int page) {} + @override + void setPageSize(int pageSize) {} + @override + void setKeyword(String keyword) {} + @override + void setCategoryId(int? categoryId) {} + @override + void reload() {} +} + +class _FakeSeriesList extends ProductSeriesListNotifier { + @override + Future> build() async => _series; + @override + void reload() {} +} + +List _overrides() => [ + productListProvider.overrideWith(() => _FakeProductList()), + productSeriesListProvider.overrideWith(() => _FakeSeriesList()), + shopInfoProvider.overrideWith((ref) => _shop), + isReadonlyProvider.overrideWithValue(false), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + // 商品档案 tab:搜索行 + 卡片流(mono 编码副行) + goldenAcrossThemes( + 'products 商品档案 窄屏(手机视口)', + goldenPrefix: 'm_products', + child: () => const Scaffold(body: ProductsScreen()), + overrides: _overrides, + logical: const Size(390, 844), + dpr: 2, + ); + + // 系列字典 tab:搜索行 + 新增按钮 + 字典卡片流(默认徽章) + goldenAcrossThemes( + 'products 系列字典 窄屏(手机视口)', + goldenPrefix: 'm_products_series', + child: () => const Scaffold(body: ProductsScreen(initialTab: 2)), + overrides: _overrides, + logical: const Size(390, 844), + dpr: 2, + ); +}