diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index 4dde13f..c578c2f 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -22,6 +22,8 @@ import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_bar_chart.dart'; import '../../widgets/ds/ds_kpi.dart'; import '../../widgets/ds/ds_table.dart'; +import '../../widgets/ds/m_kpi_grid.dart'; +import '../../widgets/ds/status_icon_map.dart'; import '../../widgets/finance_entry_dialog.dart'; import '../../widgets/finance_partner_drawer.dart'; import '../../widgets/mobile_list_card.dart'; @@ -42,6 +44,8 @@ class _FinanceScreenState extends ConsumerState { String _rangeLabel = '本月'; // 流水类型筛选(原型 3 chips 扩到 5) String _flowChip = '全部'; + // 窄屏三分段(原型 m-finance .seg:应收 / 应付 / 流水) + int _mSeg = 0; static const _flowChips = ['全部', '应收', '应付', '收款', '付款']; static const _chipToType = { @@ -139,6 +143,40 @@ class _FinanceScreenState extends ConsumerState { final flowsAsync = ref.watch(financeListProvider); final flows = flowsAsync.valueOrNull?.data ?? const []; + // 窄屏(原型 m-finance):隐藏页内大标题头,KPI 2×2 网格 + 三分段 + 卡片流。 + if (mobile) { + final content = Container( + color: t.bg, + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppDims.sp4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _timeRow(t, true), + _mKpis(t), + const SizedBox(height: 14), + Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DsSeg( + items: const ['应收', '应付', '流水'], + index: _mSeg, + onChanged: (i) => setState(() => _mSeg = i)), + ), + ), + ..._mSegBody(t, flows), + ], + ), + ), + ); + return Stack(children: [ + content, + if (flowsAsync.isLoading) + const Positioned.fill(child: DsLoadingScrim()), + ]); + } + final content = Container( color: t.bg, child: SingleChildScrollView( @@ -645,4 +683,184 @@ class _FinanceScreenState extends ConsumerState { : null, ); } + + // ── 窄屏形态(原型 m-finance)────────────────────────────────────── + + /// delta 箭头/色调映射(与 DsKpi 同规则:▲/▼ 文本字符)。 + String _mDeltaText(String text, DsKpiDelta tone) => switch (tone) { + DsKpiDelta.up => '▲ $text', + DsKpiDelta.down => '▼ $text', + DsKpiDelta.neutral => text, + }; + + MKpiDeltaTone _mTone(DsKpiDelta tone) => switch (tone) { + DsKpiDelta.up => MKpiDeltaTone.up, + DsKpiDelta.down => MKpiDeltaTone.down, + DsKpiDelta.neutral => MKpiDeltaTone.normal, + }; + + /// 窄屏 KPI 2×2(原型 .m-kpi:本月收入 / 本月支出 / 应收合计 / 应付合计)。 + Widget _mKpis(dynamic t) { + final outSum = ref.watch(stockOutSummaryProvider).valueOrNull; + final inSum = ref.watch(stockInSummaryProvider).valueOrNull; + final rows = ref.watch(financePartnerRowsProvider).valueOrNull ?? + const []; + var recv = 0.0, pay = 0.0, openCount = 0; + for (final r in rows) { + recv += r.recv; + pay += r.pay; + openCount += r.openCount; + } + final (saleDelta, saleTone) = outSum != null + ? _momDelta(outSum.monthAmount, outSum.lastMonthAmount) + : ('较上月 —', DsKpiDelta.neutral); + final (buyDelta, buyTone) = inSum != null + ? _momDelta(inSum.monthAmount, inSum.lastMonthAmount) + : ('较上月 —', DsKpiDelta.neutral); + return MKpiGrid(items: [ + MKpiItem( + label: '本月收入', + value: outSum != null ? _yuanWan(outSum.monthAmount) : '—', + delta: _mDeltaText(saleDelta, saleTone), + deltaTone: _mTone(saleTone), + ), + MKpiItem( + label: '本月支出', + value: inSum != null ? _yuanWan(inSum.monthAmount) : '—', + delta: _mDeltaText(buyDelta, buyTone), + deltaTone: _mTone(buyTone), + ), + // 原型 delta「12 笔未结」:应收/应付分侧笔数无数据源 → 用未结清总笔数(已知差异) + MKpiItem( + label: '应收合计', + value: _yuanWan(recv), + delta: '未结清 $openCount 笔', + deltaTone: MKpiDeltaTone.warn, + ), + MKpiItem( + label: '应付合计', + value: _yuanWan(pay), + delta: '按到期及时结清', + deltaTone: MKpiDeltaTone.normal, + ), + ]); + } + + /// 三分段列表体:m-section 计数标题 + 卡片流(应收/应付=往来汇总,流水=收支记录)。 + List _mSegBody(dynamic t, List flows) { + Widget section(String title, int count) => Padding( + padding: const EdgeInsets.fromLTRB(2, 16, 2, 8), + child: Text('$title · 共 $count', + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w700, + letterSpacing: .4, + color: t.muted)), + ); + Widget empty() => Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text('暂无数据', + style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))), + ); + List stacked(List cards) => [ + for (var i = 0; i < cards.length; i++) ...[ + if (i > 0) const SizedBox(height: 10), + cards[i], + ], + ]; + + if (_mSeg == 2) { + return [ + section('资金流水', flows.length), + if (flows.isEmpty) + empty() + else + ...stacked([for (final r in flows) _mFlowCard(t, r)]), + ]; + } + final async = ref.watch(financePartnerRowsProvider); + return async.when( + loading: () => [ + const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator())), + ], + error: (e, _) => [ + Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text('汇总加载失败', + style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))), + ), + ], + data: (rows) { + final ar = _mSeg == 0; + final list = rows.where((r) => ar ? r.recv > 0 : r.pay > 0).toList(); + return [ + section(ar ? '应收账款' : '应付账款', list.length), + if (list.isEmpty) + empty() + else + ...stacked( + [for (final r in list) _mPartnerCard(t, r, ar: ar)]), + ]; + }, + ); + } + + /// 应收/应付卡(原型 .m-card:往来单位 + 金额 + 徽章带图标)。 + Widget _mPartnerCard(dynamic t, PartnerFinanceRow r, {required bool ar}) { + return MobileListCard( + onTap: () => showFinancePartnerDrawer(context, row: r), + title: Text(r.name), + subtitle: Text('未结清 ${r.openCount} 笔'), + trailing: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + DsBadge('未结清', + tone: DsBadgeTone.warn, icon: statusIcon('未结清')), + const SizedBox(height: 6), + Text(_yuan(ar ? r.recv : r.pay), + style: TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w600, + fontSize: 13, + color: t.heading)), + ], + ), + ); + } + + /// 流水卡(原型 .m-card:类型徽章带图标 收款/付款 + 金额正负色)。 + Widget _mFlowCard(dynamic t, FinanceRecord r) { + final isIn = r.type == 'receipt' || r.type == 'receivable'; + return MobileListCard( + title: Text( + (r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel), + subtitle: Text((r.recordDate ?? '').split('T').first), + trailing: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + DsBadge(r.typeLabel, + tone: switch (r.type) { + 'receipt' => DsBadgeTone.ok, + 'payment' => DsBadgeTone.danger, + 'receivable' => DsBadgeTone.warn, + _ => DsBadgeTone.info, + }, + icon: statusIcon(r.typeLabel)), + const SizedBox(height: 6), + Text('${isIn ? '+' : '-'}${_yuan(r.amount.abs())}', + style: TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w600, + fontSize: 13, + color: isIn ? t.success : t.danger)), + ], + ), + ); + } } diff --git a/client/lib/screens/inventory/inventory_check_screen.dart b/client/lib/screens/inventory/inventory_check_screen.dart index c79c354..6fc0685 100644 --- a/client/lib/screens/inventory/inventory_check_screen.dart +++ b/client/lib/screens/inventory/inventory_check_screen.dart @@ -3,7 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../core/responsive/responsive.dart'; +import '../../core/theme/app_dims.g.dart'; import '../../core/theme/context_tokens.dart'; +import '../../core/utils/clock.dart'; import '../../models/inventory.dart'; import '../../models/warehouse.dart'; import '../../core/config/app_constants.dart'; @@ -12,6 +15,9 @@ import '../../providers/warehouse_provider.dart'; import '../../core/theme/app_fonts.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_toast.dart'; +import '../../widgets/ds/m_sheet.dart'; +import '../../widgets/ds/status_icon_map.dart'; +import '../../widgets/mobile_list_card.dart'; class InventoryCheckScreen extends ConsumerStatefulWidget { const InventoryCheckScreen({super.key}); @@ -31,12 +37,18 @@ class _InventoryCheckScreenState extends ConsumerState { final List<_CheckItem> _checkItems = []; String get _checkNo { - final now = DateTime.now(); + final now = appNow(); final date = '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; return 'PD$date${_selectedWarehouse?.id.toString().padLeft(3, '0') ?? '001'}'; } + /// 盘点日期(appNow 可注入时钟,golden 冻结确定化)。 + String get _dateStr { + final now = appNow(); + return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + } + @override void dispose() { for (final item in _checkItems) { @@ -81,9 +93,7 @@ class _InventoryCheckScreenState extends ConsumerState { } setState(() => _submitting = true); try { - final now = DateTime.now(); - final dateStr = - '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final dateStr = _dateStr; final items = _checkItems.map((item) { final actual = @@ -125,6 +135,10 @@ class _InventoryCheckScreenState extends ConsumerState { Widget build(BuildContext context) { final asyncWarehouses = ref.watch(warehouseListProvider); + // 窄屏(原型 m-inventory-check):壳顶栏带标题/返回 → 隐藏页内大标题头, + // 盘点单以卡片流呈现(点卡开详情 sheet),底部操作条提交。 + if (context.isMobile) return _buildMobile(asyncWarehouses); + return Scaffold( backgroundColor: context.tokens.bg, body: Column( @@ -173,90 +187,7 @@ class _InventoryCheckScreenState extends ConsumerState { child: Column( children: [ // Basic info - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('盘点基本信息', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: context.tokens.primaryDark)), - const SizedBox(height: 16), - Wrap( - spacing: 16, - runSpacing: 16, - children: [ - _InfoField( - label: '盘点单号', - child: InputDecorator( - decoration: const InputDecoration(), - child: Text( - _checkNo, - style: const TextStyle( - fontFamily: AppFonts.mono, - fontFamilyFallback: - AppFonts.monoFallback, - fontSize: 13), - ), - ), - ), - _InfoField( - label: '盘点仓库', - child: asyncWarehouses.when( - loading: () => - const LinearProgressIndicator(), - error: (e, _) => Text('$e', - style: TextStyle( - color: context.tokens.danger, - fontSize: 12)), - data: (warehouses) => DsSelect( - value: _selectedWarehouse, - hint: '请选择仓库', - options: [ - for (final w in warehouses) (w, w.name), - ], - onChanged: (w) { - setState(() => _selectedWarehouse = w); - _loadInventory(w); - }, - ), - ), - ), - _InfoField( - label: '盘点类型', - child: DsSelect( - value: _checkType, - options: const [ - ('全盘', '全盘'), - ('抽盘', '抽盘'), - ('循环盘点', '循环盘点'), - ], - onChanged: (v) => - setState(() => _checkType = v), - ), - ), - _InfoField( - label: '盘点日期', - child: InputDecorator( - decoration: const InputDecoration(), - child: Builder(builder: (ctx) { - final now = DateTime.now(); - return Text( - '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 13), - ); - }), - ), - ), - ], - ), - ], - ), - ), - ), + _basicInfoCard(asyncWarehouses), const SizedBox(height: 12), // Check items table Card( @@ -408,6 +339,256 @@ class _InventoryCheckScreenState extends ConsumerState { ); } + /// 盘点基本信息卡(桌面/移动共用):单号 / 仓库 / 类型 / 日期。 + Widget _basicInfoCard(AsyncValue> asyncWarehouses) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('盘点基本信息', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: context.tokens.primaryDark)), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _InfoField( + label: '盘点单号', + child: InputDecorator( + decoration: const InputDecoration(), + child: Text( + _checkNo, + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontSize: 13), + ), + ), + ), + _InfoField( + label: '盘点仓库', + child: asyncWarehouses.when( + loading: () => const LinearProgressIndicator(), + error: (e, _) => Text('$e', + style: TextStyle( + color: context.tokens.danger, fontSize: 12)), + data: (warehouses) => DsSelect( + value: _selectedWarehouse, + hint: '请选择仓库', + options: [ + for (final w in warehouses) (w, w.name), + ], + onChanged: (w) { + setState(() => _selectedWarehouse = w); + _loadInventory(w); + }, + ), + ), + ), + _InfoField( + label: '盘点类型', + child: DsSelect( + value: _checkType, + options: const [ + ('全盘', '全盘'), + ('抽盘', '抽盘'), + ('循环盘点', '循环盘点'), + ], + onChanged: (v) => setState(() => _checkType = v), + ), + ), + _InfoField( + label: '盘点日期', + child: InputDecorator( + decoration: const InputDecoration(), + child: Text(_dateStr, style: const TextStyle(fontSize: 13)), + ), + ), + ], + ), + ], + ), + ), + ); + } + + // ── 窄屏(原型 m-inventory-check 粒度)────────────────────────────── + + int get _totalDiff => + _checkItems.fold(0, (sum, item) => sum + _getDiff(item)); + + String get _scopeLabel => + '${_selectedWarehouse?.name ?? '未选仓库'} · $_checkType'; + + String _fmtDiff(int diff) => diff > 0 ? '+$diff' : '$diff'; + + Widget _buildMobile(AsyncValue> asyncWarehouses) { + final t = context.tokens; + return Scaffold( + backgroundColor: t.bg, + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppDims.sp3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _basicInfoCard(asyncWarehouses), + // .m-section + Padding( + padding: const EdgeInsets.fromLTRB(2, 16, 2, 8), + child: Text('盘点单', + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w700, + letterSpacing: .4, + color: t.muted)), + ), + if (_loadingItems) + const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ) + else + _draftCard(t), + ], + ), + ), + ), + // 底部操作条(原型 .m-actionbar 形态:横排等分按钮) + Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + decoration: BoxDecoration( + color: t.surface, + border: Border(top: BorderSide(color: t.borderSubtle)), + ), + child: Row(children: [ + Expanded( + child: DsButton('取消', + onPressed: () => context.go('/inventory')), + ), + const SizedBox(width: 10), + Expanded( + child: DsButton('提交盘点', + variant: DsBtnVariant.primary, + onPressed: (_submitting || _checkItems.isEmpty) + ? null + : _submit), + ), + ]), + ), + ], + ), + ); + } + + /// 盘点单卡(原型 .m-card:单号 / 范围·项数 / 状态徽章 + 差异 / 日期脚注)。 + Widget _draftCard(dynamic t) { + final diff = _totalDiff; + return MobileListCard( + onTap: _openDraftSheet, + title: Text(_checkNo, + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback)), + subtitle: Text('$_scopeLabel · ${_checkItems.length} 项'), + trailing: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + DsBadge('进行中', + tone: DsBadgeTone.warn, icon: statusIcon('进行中')), + const SizedBox(height: 6), + // 「差异」为 CJK 走默认字体,数值走 mono(JetBrains Mono 无 CJK 字形) + Text.rich( + TextSpan(children: [ + TextSpan( + text: '差异 ', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 13, + color: diff == 0 ? t.success : t.danger)), + TextSpan( + text: _fmtDiff(diff), + style: TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w700, + fontSize: 13, + color: diff == 0 ? t.success : t.danger)), + ]), + ), + ], + ), + fields: [MobileCardField('日期', _dateStr)], + ); + } + + /// 盘点单详情 sheet(原型 openSheet:.drow 键值行 + 状态徽章 + 继续录入)。 + void _openDraftSheet() { + final t = context.tokens; + final diff = _totalDiff; + showMSheet( + context, + title: '盘点单详情', + builder: (ctx) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + _drow(ctx, '单号', value: _checkNo, mono: true), + _drow(ctx, '范围', value: _scopeLabel), + _drow(ctx, '商品项数', value: '${_checkItems.length} 项'), + _drow(ctx, '盈亏差异', + value: _fmtDiff(diff), + mono: true, + color: diff == 0 ? t.success : t.danger), + _drow(ctx, '状态', + child: DsBadge('进行中', + tone: DsBadgeTone.warn, icon: statusIcon('进行中')), + last: true), + ], + ), + actions: [ + DsButton('继续录入', + variant: DsBtnVariant.primary, + onPressed: () => Navigator.of(context).pop()), + ], + ); + } + + /// 原型 .drow:label(muted) + b(text 600),pad 11 0,下边 border-subtle。 + Widget _drow(BuildContext ctx, String label, + {String? value, + Widget? child, + bool mono = false, + Color? color, + bool last = false}) { + final t = ctx.tokens; + return 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(), + child ?? + Text(value ?? '', + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + fontFamily: mono ? AppFonts.mono : null, + fontFamilyFallback: mono ? AppFonts.monoFallback : null, + color: color ?? t.text)), + ]), + ); + } + TableRow _buildCheckRow(int index) { final item = _checkItems[index]; final diff = _getDiff(item); diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index 927f14f..5b5f2d9 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -21,6 +21,10 @@ 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/ds/m_kpi_grid.dart'; +import '../../widgets/ds/m_search_row.dart'; +import '../../widgets/ds/m_sheet.dart'; +import '../../widgets/ds/status_icon_map.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef; import '../../widgets/label_preview_dialog.dart'; @@ -172,6 +176,129 @@ class _InventoryListScreenState extends ConsumerState { ); } + // ── 窄屏筛选 sheet(原型移动无列头漏斗 → 详搜钮开底部 sheet)── + bool get _mobileFilterActive => + _statusFilter != '全部' || + _filterSpec.isNotEmpty || + _filterSeries.isNotEmpty || + _filterWarehouse.isNotEmpty; + + void _openMobileFilterSheet( + List specOptions, List seriesOptions) { + showMSheet( + context, + title: '筛选', + builder: (sheetCtx) => StatefulBuilder(builder: (sheetCtx, setSheet) { + Widget group(String label, List chips) => Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w600, + color: sheetCtx.tokens.muted)), + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: chips), + ], + ), + ); + void toggleMulti( + String v, + Set selected, + ValueChanged> apply, + ) { + final next = Set.of(selected); + next.contains(v) ? next.remove(v) : next.add(v); + apply(next); + setSheet(() {}); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + group('状态', [ + for (final s in _statusOptions) + DsChip( + label: s, + selected: _statusFilter == s, + caret: false, + onTap: () { + setState(() => _statusFilter = s); + setSheet(() {}); + }), + ]), + if (specOptions.isNotEmpty) + group('规格', [ + for (final o in specOptions) + DsChip( + label: o, + selected: _filterSpec.contains(o), + caret: false, + onTap: () => toggleMulti(o, _filterSpec, (next) { + setState(() => _filterSpec = next); + ref + .read(inventoryListProvider.notifier) + .setSpec(next.toList()); + })), + ]), + if (seriesOptions.isNotEmpty) + group('系列', [ + for (final o in seriesOptions) + DsChip( + label: o, + selected: _filterSeries.contains(o), + caret: false, + onTap: () => toggleMulti(o, _filterSeries, (next) { + setState(() => _filterSeries = next); + ref + .read(inventoryListProvider.notifier) + .setSeries(next.toList()); + })), + ]), + Row(children: [ + Expanded( + child: DsButton('重置', onPressed: () { + setState(() { + _statusFilter = '全部'; + _filterSpec = {}; + _filterSeries = {}; + _filterWarehouse = {}; + }); + final n = ref.read(inventoryListProvider.notifier); + n.setSpec([]); + n.setSeries([]); + setSheet(() {}); + }), + ), + const SizedBox(width: 10), + Expanded( + child: DsButton('完成', + variant: DsBtnVariant.primary, + onPressed: () => Navigator.of(sheetCtx).pop()), + ), + ]), + ], + ); + }), + ); + } + + // ── 窄屏 KPI(MKpiGrid):delta 箭头/色调映射(与 DsKpi 同规则)── + String _mDeltaText(String text, DsKpiDelta tone) => switch (tone) { + DsKpiDelta.up => '▲ $text', + DsKpiDelta.down => '▼ $text', + DsKpiDelta.neutral => text, + }; + + MKpiDeltaTone _mTone(DsKpiDelta tone) => switch (tone) { + DsKpiDelta.up => MKpiDeltaTone.up, + DsKpiDelta.down => MKpiDeltaTone.down, + DsKpiDelta.neutral => MKpiDeltaTone.normal, + }; + /// 备注列展示:editable 时附带编辑图标(用于 WriteGuard 的可点子控件), /// 否则纯文本(只读角色占位)。 Widget _remarkDisplay(Inventory item, {required bool editable}) { @@ -549,13 +676,12 @@ class _InventoryListScreenState extends ConsumerState { child: Column( children: [ // 头部(原型 .head{margin-bottom:18px}):标题 + SKU 数 + 列设置/导出 + // 窄屏隐藏(原型 m-inventory:标题在壳顶栏 m-top) + if (!mobile) Container( width: double.infinity, color: context.tokens.bg, - 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: [ @@ -610,6 +736,47 @@ class _InventoryListScreenState extends ConsumerState { final (qtyDelta, qtyTone) = summary != null ? _momDelta(summary.inStockQty, summary.lastMonthQty) : ('较上月 —', DsKpiDelta.neutral); + if (context.isMobile) { + // 原型 m-inventory .m-kpi:2×2 网格(缺货卡 warn 色 + 点击筛选) + return Container( + color: context.tokens.bg, + padding: const EdgeInsets.all(AppDims.sp3), + child: MKpiGrid(items: [ + MKpiItem( + label: 'SKU 总数', + value: NumberFormat.decimalPattern() + .format(summary?.skuCount ?? result.total), + delta: _mDeltaText(skuDelta, skuTone), + deltaTone: _mTone(skuTone), + onTap: _clearFilters, + ), + MKpiItem( + label: '库存货值', + value: summary != null + ? yuanWan(summary.stockValue) + : '—', + delta: _mDeltaText(valDelta, valTone), + deltaTone: _mTone(valTone), + ), + MKpiItem( + label: '在库数量', + value: NumberFormat.decimalPattern() + .format((summary?.inStockQty ?? 0).round()), + delta: _mDeltaText(qtyDelta, qtyTone), + deltaTone: _mTone(qtyTone), + ), + MKpiItem( + label: '缺货预警', + value: '${summary?.shortageCount ?? 0}', + delta: '需补货 ${summary?.warningCount ?? 0} 项', + deltaTone: MKpiDeltaTone.warn, + selected: _statusFilter == '缺货', + onTap: () => + setState(() => _statusFilter = '缺货'), + ), + ]), + ); + } final cards = [ DsKpi( title: 'SKU 总数', @@ -651,36 +818,25 @@ class _InventoryListScreenState extends ConsumerState { onTap: () => setState(() => _statusFilter = '缺货'), ), ]; - final mobile = context.isMobile; final row = []; for (var i = 0; i < cards.length; i++) { if (i > 0) { // 原型 .kpis{gap:14px} - row.add(SizedBox(width: mobile ? AppDims.sp3 : 14)); + row.add(const SizedBox(width: 14)); } - row.add(mobile - ? SizedBox(width: 160, child: cards[i]) - : Expanded(child: cards[i])); + row.add(Expanded(child: cards[i])); } return Container( color: context.tokens.bg, // 原型 .kpis{margin-bottom:20px};左右留白由外层 .main 统一给 - padding: mobile - ? const EdgeInsets.all(AppDims.sp3) - : const EdgeInsets.only(bottom: 20), - child: mobile - ? SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: IntrinsicHeight(child: Row(children: row)), - ) - : IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: row)), + padding: const EdgeInsets.only(bottom: 20), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: row)), ); }), - // 原型 KPI 与表格卡之间无分隔线(桌面);窄屏保留视觉分隔 - if (mobile) const Divider(height: 1), + // 原型 KPI 与表格卡之间无分隔线(桌面/移动同) Expanded( child: DsTable( total: result.total, @@ -707,11 +863,21 @@ class _InventoryListScreenState extends ConsumerState { final canCheck = !WriteGuard.isReadonly(ref); if (isMobile) { - // 移动端:搜索独占一行 + 紧凑图标操作行(含 表格/列表 切换) + // 移动端:MSearchRow(原型 .m-search + 详搜钮)独占一行 + // + 紧凑图标操作行(含 表格/列表 切换) return Column( mainAxisSize: MainAxisSize.min, children: [ - searchField, + MSearchRow( + controller: _searchCtrl, + hint: '商品名 / 拼音 / 编码', + onSubmitted: (v) => ref + .read(inventoryListProvider.notifier) + .setKeyword(v.trim()), + filterActive: _mobileFilterActive, + onFilterTap: () => _openMobileFilterSheet( + specOptions, seriesOptions), + ), const SizedBox(height: AppDims.sp2), Row( children: [ @@ -901,13 +1067,26 @@ class _InventoryStatusBadge extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.tokens; - // 状态派生 qty vs min_stock(在售 / 预警 / 缺货),软底+圆点全走 token。 + // 状态派生 qty vs min_stock(在售 / 预警 / 缺货),软底 + 图标变体 + //(2026-07-04 拍板:圆点 → 代表图标,映射走 status_icon_map)。 if (item.quantity == 0) { - return StatusPill(label: '缺货', color: t.danger, background: t.dangerBg); + return StatusPill( + label: '缺货', + color: t.danger, + background: t.dangerBg, + icon: statusIcon('缺货')); } if (item.minStock != null && item.quantity < item.minStock!) { - return StatusPill(label: '预警', color: t.warn, background: t.warnBg); + return StatusPill( + label: '预警', + color: t.warn, + background: t.warnBg, + icon: statusIcon('预警')); } - return StatusPill(label: '在售', color: t.success, background: t.okSoft); + return StatusPill( + label: '在售', + color: t.success, + background: t.okSoft, + icon: statusIcon('在售')); } } diff --git a/client/test/golden/finance_mobile_golden_test.dart b/client/test/golden/finance_mobile_golden_test.dart new file mode 100644 index 0000000..3dc0e12 --- /dev/null +++ b/client/test/golden/finance_mobile_golden_test.dart @@ -0,0 +1,126 @@ +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/models/page_result.dart'; +import 'package:jiu_client/core/utils/clock.dart'; +import 'package:jiu_client/models/finance.dart'; +import 'package:jiu_client/models/stock_summary.dart'; +import 'package:jiu_client/providers/finance_provider.dart'; +import 'package:jiu_client/providers/stock_in_provider.dart'; +import 'package:jiu_client/providers/stock_out_provider.dart'; +import 'package:jiu_client/screens/finance/finance_screen.dart'; + +import '../support/golden_harness.dart'; + +/// 财务管理 窄屏形态 golden × 三主题(390×844 @2x)——对齐原型 m-finance.html: +/// 隐藏页内大标题头 + KPI 2×2(收入 up 绿 / 支出 down 红)+ DsSeg 三分段 +/// + 应收卡片流(往来单位 + 金额 + 未结清徽章带图标)。fixtures 与桌面 golden 同套。 +/// 更新基准:flutter test --update-goldens test/golden/finance_mobile_golden_test.dart + +// 原型 SUMMARY 8 行 +const _rows = [ + PartnerFinanceRow(partnerId: 1, name: '华东酒类批发', recv: 128400, openCount: 2), + PartnerFinanceRow(partnerId: 2, name: '金樽商贸', recv: 86200, openCount: 1), + PartnerFinanceRow(partnerId: 3, name: '茅台华南经销', pay: 142800, openCount: 1), + PartnerFinanceRow(partnerId: 4, name: '鸿运餐饮连锁', recv: 74600, openCount: 1), + PartnerFinanceRow(partnerId: 5, name: '五粮液省级代理', pay: 96400, openCount: 1), + PartnerFinanceRow( + partnerId: 6, name: '盛世名酒城', recv: 52300, pay: 18600, openCount: 2), + PartnerFinanceRow(partnerId: 7, name: '御品烟酒行', recv: 31800, openCount: 1), + PartnerFinanceRow(partnerId: 8, name: '川渝糖酒集团', pay: 60400, openCount: 1), +]; + +// 原型 FLOWS 前 5 条(流水分段用;390×844 首屏展示应收卡片流) +const _flows = [ + FinanceRecord( + id: 1, + type: 'receipt', + amount: 68000, + balance: 0, + status: 'closed', + partnerName: '华东酒类批发', + recordDate: '2026-06-20', + remark: '飞天茅台货款'), + FinanceRecord( + id: 2, + type: 'payment', + amount: 142800, + balance: 0, + status: 'closed', + partnerName: '茅台华南经销', + recordDate: '2026-06-19', + remark: '6月采购结算'), + FinanceRecord( + id: 3, + type: 'receipt', + amount: 42600, + balance: 0, + status: 'closed', + partnerName: '金樽商贸', + recordDate: '2026-06-18', + remark: '五粮液尾款'), + FinanceRecord( + id: 4, + type: 'receipt', + amount: 31500, + balance: 0, + status: 'closed', + partnerName: '鸿运餐饮连锁', + recordDate: '2026-06-17', + remark: '宴会用酒'), + FinanceRecord( + id: 5, + type: 'payment', + amount: 96400, + balance: 0, + status: 'closed', + partnerName: '五粮液省级代理', + recordDate: '2026-06-16', + remark: '普五补货'), +]; + +class _FakeFinanceList extends FinanceListNotifier { + @override + Future> build() async => + const PageResult(data: _flows, total: 5, page: 1, pageSize: 50); + @override + void setType(String type) {} + @override + void setMonth(String month) {} + @override + void setRange(String startDate, String endDate) {} + @override + void setPage(int page) {} + @override + void setPageSize(int pageSize) {} + @override + void reload() {} +} + +List _overrides() => [ + financeListProvider.overrideWith(() => _FakeFinanceList()), + financePartnerRowsProvider.overrideWith((ref) async => _rows), + // 环比凑成原型 ▲8.4% / ▼3.1% + stockOutSummaryProvider.overrideWith((ref) async => + const StockSummary(monthAmount: 1860000, lastMonthAmount: 1715867)), + stockInSummaryProvider.overrideWith((ref) async => + const StockSummary(monthAmount: 1320000, lastMonthAmount: 1362229)), + isReadonlyProvider.overrideWithValue(false), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + appClock = () => DateTime(2026, 7, 13, 9, 30, 15); + + goldenAcrossThemes( + 'finance 窄屏(m-finance)', + goldenPrefix: 'm_finance', + child: () => const Scaffold(body: FinanceScreen()), + overrides: _overrides, + logical: const Size(390, 844), + dpr: 2, + ); +} diff --git a/client/test/golden/goldens/finance_mobile_a.png b/client/test/golden/goldens/finance_mobile_a.png index 8da3a91..f21f762 100644 Binary files a/client/test/golden/goldens/finance_mobile_a.png and b/client/test/golden/goldens/finance_mobile_a.png differ diff --git a/client/test/golden/goldens/finance_mobile_b.png b/client/test/golden/goldens/finance_mobile_b.png index 1ae9267..10047d2 100644 Binary files a/client/test/golden/goldens/finance_mobile_b.png and b/client/test/golden/goldens/finance_mobile_b.png differ diff --git a/client/test/golden/goldens/finance_mobile_c.png b/client/test/golden/goldens/finance_mobile_c.png index c271eba..ee741d0 100644 Binary files a/client/test/golden/goldens/finance_mobile_c.png and b/client/test/golden/goldens/finance_mobile_c.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_a.png b/client/test/golden/goldens/inventory_list_mobile_a.png index ec3e544..4adb732 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_a.png and b/client/test/golden/goldens/inventory_list_mobile_a.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_b.png b/client/test/golden/goldens/inventory_list_mobile_b.png index ae9b5f7..d1bc4bf 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_b.png and b/client/test/golden/goldens/inventory_list_mobile_b.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_c.png b/client/test/golden/goldens/inventory_list_mobile_c.png index 9b3b4a2..b4d0fbe 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_c.png and b/client/test/golden/goldens/inventory_list_mobile_c.png differ diff --git a/client/test/golden/goldens/m_finance_a.png b/client/test/golden/goldens/m_finance_a.png new file mode 100644 index 0000000..f100c3a Binary files /dev/null and b/client/test/golden/goldens/m_finance_a.png differ diff --git a/client/test/golden/goldens/m_finance_b.png b/client/test/golden/goldens/m_finance_b.png new file mode 100644 index 0000000..e792e1c Binary files /dev/null and b/client/test/golden/goldens/m_finance_b.png differ diff --git a/client/test/golden/goldens/m_finance_c.png b/client/test/golden/goldens/m_finance_c.png new file mode 100644 index 0000000..11575e9 Binary files /dev/null and b/client/test/golden/goldens/m_finance_c.png differ diff --git a/client/test/golden/goldens/m_inventory_a.png b/client/test/golden/goldens/m_inventory_a.png new file mode 100644 index 0000000..9d006a8 Binary files /dev/null and b/client/test/golden/goldens/m_inventory_a.png differ diff --git a/client/test/golden/goldens/m_inventory_b.png b/client/test/golden/goldens/m_inventory_b.png new file mode 100644 index 0000000..ba6794a Binary files /dev/null and b/client/test/golden/goldens/m_inventory_b.png differ diff --git a/client/test/golden/goldens/m_inventory_c.png b/client/test/golden/goldens/m_inventory_c.png new file mode 100644 index 0000000..965f27c Binary files /dev/null and b/client/test/golden/goldens/m_inventory_c.png differ diff --git a/client/test/golden/goldens/m_inventory_check_a.png b/client/test/golden/goldens/m_inventory_check_a.png new file mode 100644 index 0000000..cc4a06e Binary files /dev/null and b/client/test/golden/goldens/m_inventory_check_a.png differ diff --git a/client/test/golden/goldens/m_inventory_check_b.png b/client/test/golden/goldens/m_inventory_check_b.png new file mode 100644 index 0000000..c46000e Binary files /dev/null and b/client/test/golden/goldens/m_inventory_check_b.png differ diff --git a/client/test/golden/goldens/m_inventory_check_c.png b/client/test/golden/goldens/m_inventory_check_c.png new file mode 100644 index 0000000..daf7c28 Binary files /dev/null and b/client/test/golden/goldens/m_inventory_check_c.png differ diff --git a/client/test/golden/inventory_check_mobile_golden_test.dart b/client/test/golden/inventory_check_mobile_golden_test.dart new file mode 100644 index 0000000..6001f6c --- /dev/null +++ b/client/test/golden/inventory_check_mobile_golden_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/utils/clock.dart'; +import 'package:jiu_client/models/warehouse.dart'; +import 'package:jiu_client/providers/warehouse_provider.dart'; +import 'package:jiu_client/screens/inventory/inventory_check_screen.dart'; + +import '../support/golden_harness.dart'; + +/// 库存盘点 窄屏形态 golden × 三主题(390×844 @2x)——对齐原型 m-inventory-check.html +/// 粒度:基本信息卡 + 「盘点单」卡片流(单号/范围·项数/进行中徽章带图标/差异/日期) +/// + 底部操作条(取消/提交盘点)。时钟冻结保证单号/日期确定化。 +/// 更新基准:flutter test --update-goldens test/golden/inventory_check_mobile_golden_test.dart + +const _warehouses = [ + Warehouse(id: 1, name: '主仓', isDefault: true), + Warehouse(id: 2, name: '东库', isDefault: false), +]; + +class _FakeWarehouses extends WarehouseListNotifier { + @override + Future> build() async => _warehouses; +} + +List _overrides() => [ + warehouseListProvider.overrideWith(() => _FakeWarehouses()), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + appClock = () => DateTime(2026, 6, 20, 9, 30, 15); + + goldenAcrossThemes( + 'inventory check 窄屏(m-inventory-check)', + goldenPrefix: 'm_inventory_check', + child: () => const InventoryCheckScreen(), + overrides: _overrides, + logical: const Size(390, 844), + dpr: 2, + ); +} diff --git a/client/test/golden/inventory_list_mobile_golden_test.dart b/client/test/golden/inventory_list_mobile_golden_test.dart new file mode 100644 index 0000000..425bbd8 --- /dev/null +++ b/client/test/golden/inventory_list_mobile_golden_test.dart @@ -0,0 +1,192 @@ +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/inventory.dart'; +import 'package:jiu_client/models/product_option.dart'; +import 'package:jiu_client/providers/inventory_provider.dart'; +import 'package:jiu_client/providers/product_option_provider.dart'; +import 'package:jiu_client/screens/inventory/inventory_list_screen.dart'; + +import '../support/golden_harness.dart'; + +/// 库存列表 窄屏形态 golden × 三主题(390×844 @2x)——对齐原型 m-inventory.html: +/// 隐藏页内大标题头 + KPI 2×2 MKpiGrid(缺货卡 warn)+ MSearchRow + 卡片流图标徽章。 +/// 更新基准:flutter test --update-goldens test/golden/inventory_list_mobile_golden_test.dart + +// 原型 ITEMS 前 10 行(与桌面 golden 同套 fixtures)。 +const _items = [ + Inventory( + id: 1, + productId: 1, + quantity: 128, + productCode: 'MT-FT-500', + productName: '茅台 飞天 53°', + series: '飞天', + spec: '500ml×6', + unit: '件', + warehouseName: '主仓', + unitPrice: 2680, + salePrice: 2680, + productionDate: '2024-07-11', + batchNo: 'PZ240711', + supplierName: '贵州茅台经销', + minStock: 10, + createdAt: '2026-06-14', + ), + Inventory( + id: 2, + productId: 2, + quantity: 64, + productCode: 'WLY-PW-500', + productName: '五粮液 普五 52°', + series: '普五', + spec: '500ml×6', + unit: '件', + warehouseName: '主仓', + unitPrice: 1050, + salePrice: 1050, + productionDate: '2024-04-28', + batchNo: 'PZ240428', + supplierName: '宜宾五粮液', + minStock: 10, + createdAt: '2026-06-14', + ), + Inventory( + id: 3, + productId: 3, + quantity: 8, + productCode: 'JNC-SJ-500', + productName: '剑南春 水晶剑', + series: '水晶剑', + spec: '500ml×6', + unit: '件', + warehouseName: '主仓', + unitPrice: 438, + salePrice: 438, + productionDate: '2023-11-15', + batchNo: 'PZ231115', + supplierName: '绵竹剑南春', + minStock: 10, + createdAt: '2026-06-12', + ), + Inventory( + id: 8, + productId: 8, + quantity: 0, + productCode: 'FJ-QH20-500', + productName: '汾酒 青花 20', + series: '青花20', + spec: '500ml×6', + unit: '件', + warehouseName: '主仓', + unitPrice: 420, + salePrice: 420, + productionDate: '2024-01-12', + batchNo: 'PZ240112', + supplierName: '山西杏花村', + minStock: 10, + createdAt: '2026-06-03', + ), + Inventory( + id: 4, + productId: 4, + quantity: 42, + productCode: 'GJ-1573-500', + productName: '国窖 1573', + series: '1573', + spec: '500ml×6', + unit: '件', + warehouseName: '主仓', + unitPrice: 980, + salePrice: 980, + productionDate: '2024-02-08', + batchNo: 'PZ240208', + supplierName: '泸州老窖', + minStock: 10, + createdAt: '2026-06-10', + ), + Inventory( + id: 5, + productId: 5, + quantity: 23, + productCode: 'YH-M6-500', + productName: '洋河 梦之蓝 M6', + series: '梦之蓝M6', + spec: '550ml×6', + unit: '件', + warehouseName: '主仓', + unitPrice: 620, + salePrice: 620, + productionDate: '2024-05-20', + batchNo: 'PZ240520', + supplierName: '洋河股份', + minStock: 30, + createdAt: '2026-06-09', + ), +]; + +class _FakeInvNotifier extends InventoryListNotifier { + @override + Future> build() async => + const PageResult(data: _items, total: 6, page: 1, pageSize: 10); + @override + void setPage(int page) {} + @override + void setPageSize(int pageSize) {} + @override + void setWarehouseId(int? id) {} + @override + void setKeyword(String keyword) {} + @override + void setCode(String code) {} + @override + void setSeries(List series) {} + @override + void setSpec(List spec) {} + @override + void reload() {} +} + +class _FakeSeriesNotifier extends ProductSeriesListNotifier { + @override + Future> build() async => const []; +} + +class _FakeSpecNotifier extends ProductSpecListNotifier { + @override + Future> build() async => const []; +} + +List _overrides() => [ + inventoryListProvider.overrideWith(() => _FakeInvNotifier()), + productSeriesListProvider.overrideWith(() => _FakeSeriesNotifier()), + productSpecListProvider.overrideWith(() => _FakeSpecNotifier()), + isReadonlyProvider.overrideWithValue(false), + inventorySummaryProvider.overrideWith((ref) => const InventorySummary( + skuCount: 1284, + stockValue: 2640000, + inStockQty: 18920, + shortageCount: 17, + warningCount: 6, + lastMonthSku: 1255, + lastMonthValue: 2611276, + lastMonthQty: 19034, + )), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + goldenAcrossThemes( + 'inventory list 窄屏(m-inventory)', + goldenPrefix: 'm_inventory', + child: () => const Scaffold(body: InventoryListScreen()), + overrides: _overrides, + logical: const Size(390, 844), + dpr: 2, + ); +}