feat(client): 往来/基础数据/商品详情窄屏对齐移动原型(卡片流/图标徽章/sheet 表单)

- 往来单位:窄屏隐藏页内大标题(标题在壳顶栏);MSearchRow 搜索行;
  类型 pills 全部/供应商/客户/两者(两者=客户端过滤,服务端 FIND_IN_SET 无独立值);
  卡片流类型+状态徽章换图标变体、mc-sub mono 编码
- 基础数据:窄屏隐藏大标题;各 tab 搜索行换 MSearchRow(清空 × 并入,重置钮撤);
  档案/字典卡片副行 mono 编码;系列/规格/仓库 新增编辑窄屏走 showMSheet 底部 sheet 轻表单
- 商品详情:窄屏 padding 收紧 sp4;信息卡 Wrap→单列;图片缩略图行窄屏横滚防溢出;
  二维码弹窗宽度走 context.dialogWidth
- golden:新增 m_partners / m_products / m_products_series(390×844 @2x 三主题);
  重生成既有 partners_mobile / products_archive_mobile 基准(桌面 golden 零漂移)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-04 12:13:38 +08:00
parent be9afcf68d
commit 4287f5a876
20 changed files with 665 additions and 260 deletions
+115 -61
View File
@@ -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<PartnersScreen> {
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<PartnersScreen> {
),
),
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<PartnersScreen> {
onPressed: () => _openForm()),
),
],
],
),
),
),
Expanded(
child: DsTable(
pagerInfoText: '${result.total} 个往来单位',
@@ -167,18 +172,61 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
);
}
// ── toolbar(原型:searchbox 260 + chips + sp + 重置)──
// ── toolbar(原型:searchbox 260 + chips + sp + 重置
// 窄屏对齐 m-partners.m-search 搜索行 + .m-pill 类型横排)──
Widget _toolbar(bool mobile, List<Partner> 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 的既有对应物 DsChipselected 态)
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<PartnersScreen> {
.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<PartnersScreen> {
);
}
// ── 窄屏卡片(m-partners:右侧类型徽章 + 应收/应付)──
/// 类型徽章图标变体(原型 m-partnersbadge b-供应商/客户/两者 + BADGE_ICON)。
/// 色调与 partnerTypeBadgepartner_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-partnersmc-sub mono 编码 + 右侧类型/状态图标徽章)──
Widget _card(Partner p, Map<int, ({double recv, double pay})> 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),
@@ -291,7 +291,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
}
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<ProductDetailScreen> {
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 = <Widget>[
...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<ProductDetailScreen> {
),
);
// (行组件, 桌面 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<ProductDetailScreen> {
),
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<Widget>? _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<Uint8List>(
future: _future,
+224 -130
View File
@@ -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<ProductsScreen> {
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<ProductsScreen> {
),
],
],
],
),
),
),
// ── .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<ProductsScreen> {
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<ProductsScreen> {
onTap: () => showProductEditorDrawer(context,
productId: p.id, cost: p.purchasePrice),
title: Text(p.name),
subtitle: p.code.isEmpty ? null : Text(p.code),
// 原型 m-products mc-submono 编码
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<ProductsScreen> {
mobileCards: filtered
.map((r) => MobileListCard(
title: Text(r.name),
subtitle: (r.code ?? '').isEmpty ? null : Text(r.code!),
// 原型 m-products mc-submono 编码
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<ProductsScreen> {
TextEditingController(text: initial?['remark'] as String? ?? '');
final formKey = GlobalKey<FormState>();
List<Widget> 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<void> submit(BuildContext ctx) async {
if (!formKey.currentState!.validate()) return;
final data = <String, dynamic>{
'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 openDictEditm-form + 按钮行)
if (context.isMobile) {
await showMSheet<void>(
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<ProductsScreen> {
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 = <String, dynamic>{
'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<ProductsScreen> {
}
void _showWarehouseDialog({Warehouse? warehouse}) {
// 窄屏:底部 sheet 轻表单(与系列/规格字典同形态)
if (context.isMobile) {
showMSheet<void>(
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<Widget> _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(),
),
),
),
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 156 KiB

@@ -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 = <int, ({double recv, double pay})>{
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<PageResult<Partner>> 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<Override> _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,
);
}
@@ -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<PageResult<Product>> 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<List<ProductSeriesOption>> build() async => _series;
@override
void reload() {}
}
List<Override> _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,
);
}