diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index 557cd3f..5e1ff35 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -27,6 +27,7 @@ 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/m_search_row.dart'; import '../../widgets/ds/m_sheet.dart'; import '../../widgets/ds/status_icon_map.dart'; import '../../widgets/finance_entry_dialog.dart'; @@ -56,6 +57,10 @@ class _FinanceScreenState extends ConsumerState { DateTimeRange? _customRange; // 窄屏三分段(原型 m-finance .seg:应收 / 应付 / 流水) int _mSeg = 0; + // 应收/应付汇总卡「往来单位」搜索(本地即时过滤——financePartnerRowsProvider + // 全量加载非分页,搜索口径=显示口径;桌面 DsSearchBox / 移动 MSearchRow 共用) + final _summaryKwCtrl = TextEditingController(); + String _summaryKw = ''; static const _flowChips = ['全部', '应收', '应付', '收款', '付款']; static const _chipToType = { @@ -102,7 +107,8 @@ class _FinanceScreenState extends ConsumerState { case '自定义': final picked = await showDateRangeDropdown( anchorContext, - initial: _customRange ?? + initial: + _customRange ?? DateTimeRange(start: DateTime(now.year, now.month, 1), end: now), ); if (picked == null) return; @@ -121,6 +127,21 @@ class _FinanceScreenState extends ConsumerState { ref.read(financeListProvider.notifier).setPartner(partnerId); } + void _setSummaryKw(String kw) => setState(() => _summaryKw = kw); + + /// 汇总行本地过滤(往来单位名 contains,大小写不敏感)。 + List _filterSummaryRows(List rows) { + final kw = _summaryKw.trim().toLowerCase(); + if (kw.isEmpty) return rows; + return rows.where((r) => r.name.toLowerCase().contains(kw)).toList(); + } + + @override + void dispose() { + _summaryKwCtrl.dispose(); + super.dispose(); + } + void _reload() { ref.read(financeListProvider.notifier).reload(); ref.invalidate(financePartnerRowsProvider); @@ -130,9 +151,8 @@ class _FinanceScreenState extends ConsumerState { } // ── 金额格式 ── - String _yuan(double v) => '¥${NumberFormat.decimalPattern().format( - v == v.roundToDouble() ? v.round() : v, - )}'; + String _yuan(double v) => + '¥${NumberFormat.decimalPattern().format(v == v.roundToDouble() ? v.round() : v)}'; /// KPI 大数:¥186万 / ¥48.6万(<1万显示整数元)。 String _yuanWan(double v) { @@ -177,21 +197,34 @@ class _FinanceScreenState extends ConsumerState { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: DsSeg( - items: const ['应收', '应付', '流水'], - index: _mSeg, - onChanged: (i) => setState(() => _mSeg = i)), + items: const ['应收', '应付', '流水'], + index: _mSeg, + onChanged: (i) => setState(() => _mSeg = i), + ), ), ), + // 应收/应付分段头部往来单位搜索(同口径本地过滤);「流水」分段 + // 沿用上方时间行「往来单位」chip → sheet 单选,不重复放置。 + if (_mSeg != 2) ...[ + const SizedBox(height: 12), + MSearchRow( + controller: _summaryKwCtrl, + hint: '搜索往来单位', + onChanged: _setSummaryKw, + ), + ], ..._mSegBody(t, flows), ], ), ), ); - return Stack(children: [ - content, - if (flowsAsync.isLoading) - const Positioned.fill(child: DsLoadingScrim()), - ]); + return Stack( + children: [ + content, + if (flowsAsync.isLoading) + const Positioned.fill(child: DsLoadingScrim()), + ], + ); } final content = Container( @@ -209,7 +242,17 @@ class _FinanceScreenState extends ConsumerState { _secHead(t, '收支趋势', '近 6 个月 · 单位 万元'), _trendChart(), const SizedBox(height: 22), - _secHead(t, '应收 / 应付 汇总', '按往来单位 · 点击行查看流水'), + _secHead( + t, + '应收 / 应付 汇总', + '按往来单位 · 点击行查看流水', + trailing: DsSearchBox( + controller: _summaryKwCtrl, + hint: '搜索往来单位', + width: 220, + onChanged: _setSummaryKw, + ), + ), _summaryTable(t, mobile), const SizedBox(height: 22), _secHead(t, '收支流水', _flowSub(flows)), @@ -218,10 +261,13 @@ class _FinanceScreenState extends ConsumerState { ), ), ); - return Stack(children: [ - content, - if (flowsAsync.isLoading) const Positioned.fill(child: DsLoadingScrim()), - ]); + return Stack( + children: [ + content, + if (flowsAsync.isLoading) + const Positioned.fill(child: DsLoadingScrim()), + ], + ); } // ── head ── @@ -231,32 +277,42 @@ class _FinanceScreenState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text('财务管理', - style: TextStyle( - fontSize: AppDims.fsH1, - fontWeight: FontWeight.w700, - color: t.heading)), + Text( + '财务管理', + style: TextStyle( + fontSize: AppDims.fsH1, + fontWeight: FontWeight.w700, + color: t.heading, + ), + ), const SizedBox(width: AppDims.sp3), Expanded( child: Padding( padding: const EdgeInsets.only(bottom: 2), - child: Text('应收/应付概览 · $_rangeLabel', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + child: Text( + '应收/应付概览 · $_rangeLabel', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted), + ), ), ), if (!mobile) ...[ DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: _reload), const SizedBox(width: 10), - DsButton('导出报表', - icon: LucideIcons.download, onPressed: () => _export(flows)), + DsButton( + '导出报表', + icon: LucideIcons.download, + onPressed: () => _export(flows), + ), const SizedBox(width: 10), WriteGuard( - child: DsButton('登记收支', - icon: LucideIcons.plus, - variant: DsBtnVariant.primary, - onPressed: () => showFinanceEntryDialog(context)), + child: DsButton( + '登记收支', + icon: LucideIcons.plus, + variant: DsBtnVariant.primary, + onPressed: () => showFinanceEntryDialog(context), + ), ), ], ], @@ -269,15 +325,17 @@ class _FinanceScreenState extends ConsumerState { filename: '财务流水', headers: ['日期', '类型', '往来单位', '金额', '关联单据', '状态', '备注'], rows: flows - .map((r) => [ - (r.recordDate ?? '').split('T').first, - r.typeLabel, - r.partnerName ?? '', - r.amount, - r.refType != null ? '${r.docTitle}#${r.refId ?? ''}' : '', - r.status == 'open' ? '未结清' : '已结清', - r.remark ?? '', - ]) + .map( + (r) => [ + (r.recordDate ?? '').split('T').first, + r.typeLabel, + r.partnerName ?? '', + r.amount, + r.refType != null ? '${r.docTitle}#${r.refId ?? ''}' : '', + r.status == 'open' ? '未结清' : '已结清', + r.remark ?? '', + ], + ) .toList(), ); } @@ -289,35 +347,42 @@ class _FinanceScreenState extends ConsumerState { final partnerName = mobile ? _partnerNameById( ref.watch(allPartnersProvider).valueOrNull?.data ?? const [], - _partnerId) + _partnerId, + ) : null; final children = [ - Text('时间范围', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + Text( + '时间范围', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted), + ), const SizedBox(width: 12), for (final r in const ['本月', '本季']) ...[ DsChip( - label: r, - selected: _range == r, - caret: false, - onTap: () => _applyRange(r, context)), + label: r, + selected: _range == r, + caret: false, + onTap: () => _applyRange(r, context), + ), const SizedBox(width: 10), ], Builder( // anchorCtx = chip 自身位置:自定义范围下拉/sheet 锚定在 chip 下方 // (照 stock_in_list_screen.dart _dateMenu 写法) builder: (anchorCtx) => DsChip( - label: '自定义', - selected: _range == '自定义', - onTap: () => _applyRange('自定义', anchorCtx)), + label: '自定义', + selected: _range == '自定义', + onTap: () => _applyRange('自定义', anchorCtx), + ), ), if (mobile) ...[ const SizedBox(width: 10), DsChip( - label: '往来单位', - value: partnerName, - onTap: _openPartnerSheet, - onClear: () => _setPartner(null)), + label: '往来单位', + value: partnerName, + onTap: _openPartnerSheet, + onClear: () => _setPartner(null), + ), ], ]; @@ -325,11 +390,13 @@ class _FinanceScreenState extends ConsumerState { children.addAll([ const SizedBox(width: 10), WriteGuard( - child: DsButton('登记', - small: true, - icon: LucideIcons.plus, - variant: DsBtnVariant.primary, - onPressed: () => showFinanceEntryDialog(context)), + child: DsButton( + '登记', + small: true, + icon: LucideIcons.plus, + variant: DsBtnVariant.primary, + onPressed: () => showFinanceEntryDialog(context), + ), ), ]); // 窄屏内容(label+4 chip+登记)常超宽:整行横向可滚(照 _flowTable 移动 @@ -337,7 +404,9 @@ class _FinanceScreenState extends ConsumerState { return Padding( padding: const EdgeInsets.only(bottom: 18), child: SingleChildScrollView( - scrollDirection: Axis.horizontal, child: Row(children: children)), + scrollDirection: Axis.horizontal, + child: Row(children: children), + ), ); } @@ -357,14 +426,17 @@ class _FinanceScreenState extends ConsumerState { /// 移动端「往来单位」chip → 底部 sheet 可搜索单选(D6,对齐桌面 ComboSearchField)。 Future _openPartnerSheet() async { - final partners = ref.read(allPartnersProvider).valueOrNull?.data ?? const []; + final partners = + ref.read(allPartnersProvider).valueOrNull?.data ?? const []; final options = [ - for (final p in partners) OptionItem(id: p.id, name: p.name, code: p.code), + for (final p in partners) + OptionItem(id: p.id, name: p.name, code: p.code), ]; final sel = await showMSheet( context, title: '往来单位', - builder: (_) => _PartnerSheetBody(options: options, selectedId: _partnerId), + builder: (_) => + _PartnerSheetBody(options: options, selectedId: _partnerId), ); if (sel == null || !mounted) return; // -1 = 清除选择(照 SearchableOptionField 的既有约定) @@ -430,12 +502,14 @@ class _FinanceScreenState extends ConsumerState { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: IntrinsicHeight( - child: Row(children: [ - for (var i = 0; i < cards.length; i++) ...[ - if (i > 0) const SizedBox(width: AppDims.sp3), - SizedBox(width: 170, child: cards[i]), + child: Row( + children: [ + for (var i = 0; i < cards.length; i++) ...[ + if (i > 0) const SizedBox(width: AppDims.sp3), + SizedBox(width: 170, child: cards[i]), + ], ], - ]), + ), ), ), ); @@ -457,22 +531,30 @@ class _FinanceScreenState extends ConsumerState { } /// 原型 .sec-head:h2 17/700 + 副标 fs-xs muted,下距 12。 - Widget _secHead(dynamic t, String title, String sub) => Padding( + /// [trailing] 靠右附加控件(原型 .sec-head .sum-search,如汇总卡的往来单位搜索)。 + Widget _secHead(dynamic t, String title, String sub, {Widget? trailing}) => + Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text(title, - style: TextStyle( - fontSize: AppDims.fsH2, - fontWeight: FontWeight.w700, - color: t.heading)), + Text( + title, + style: TextStyle( + fontSize: AppDims.fsH2, + fontWeight: FontWeight.w700, + color: t.heading, + ), + ), const SizedBox(width: 10), Padding( padding: const EdgeInsets.only(bottom: 1), - child: Text(sub, - style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), + child: Text( + sub, + style: TextStyle(fontSize: AppDims.fsXs, color: t.muted), + ), ), + if (trailing != null) ...[const Spacer(), trailing], ], ), ); @@ -482,22 +564,29 @@ class _FinanceScreenState extends ConsumerState { final t = context.tokens; final trend = ref.watch(financeTrendProvider); Widget placeholder(Widget child) => Container( - height: 120, - alignment: Alignment.center, - decoration: BoxDecoration( - color: t.surface, - border: Border.all(color: t.border), - borderRadius: BorderRadius.circular(AppDims.rLg), - ), - child: child, - ); + height: 120, + alignment: Alignment.center, + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rLg), + ), + child: child, + ); return trend.when( - loading: () => placeholder(const SizedBox( + loading: () => placeholder( + const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2))), - error: (e, _) => placeholder(Text('趋势加载失败', - style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + error: (e, _) => placeholder( + Text( + '趋势加载失败', + style: TextStyle(color: t.muted, fontSize: AppDims.fsSm), + ), + ), data: (points) => DsBarChart( groups: [ for (final p in points) @@ -521,79 +610,110 @@ class _FinanceScreenState extends ConsumerState { final async = ref.watch(financePartnerRowsProvider); return async.when( loading: () => const Padding( - padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator())), - error: (e, _) => Row(children: [ - Text('汇总加载失败', - style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)), - const SizedBox(width: 10), - DsButton('重试', + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Row( + children: [ + Text( + '汇总加载失败', + style: TextStyle(color: t.muted, fontSize: AppDims.fsSm), + ), + const SizedBox(width: 10), + DsButton( + '重试', small: true, - onPressed: () => ref.invalidate(financePartnerRowsProvider)), - ]), - data: (rows) => DsTable( - shrinkWrap: true, - emptyText: '暂无未结清的应收 / 应付', - mobileCards: rows - .map((r) => MobileListCard( + onPressed: () => ref.invalidate(financePartnerRowsProvider), + ), + ], + ), + data: (allRows) { + final rows = _filterSummaryRows(allRows); + return DsTable( + shrinkWrap: true, + emptyText: _summaryKw.trim().isEmpty ? '暂无未结清的应收 / 应付' : '没有匹配的往来单位', + mobileCards: rows + .map( + (r) => MobileListCard( title: Text(r.name), - trailing: Text(signedYuan(r.net), - style: TextStyle( - fontWeight: FontWeight.w600, - fontFamily: AppFonts.mono, - fontFamilyFallback: AppFonts.monoFallback, - color: r.net > 0 - ? t.success - : (r.net < 0 ? t.danger : t.text))), + trailing: Text( + signedYuan(r.net), + style: TextStyle( + fontWeight: FontWeight.w600, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + color: r.net > 0 + ? t.success + : (r.net < 0 ? t.danger : t.text), + ), + ), fields: [ MobileCardField('应收', r.recv > 0 ? _yuan(r.recv) : '—'), MobileCardField('应付', r.pay > 0 ? _yuan(r.pay) : '—'), ], onTap: () => showFinancePartnerDrawer(context, row: r), - )) - .toList(), - columns: const [ - DsColumn('name', '往来单位'), - DsColumn('recv', '应收', numeric: true), - DsColumn('pay', '应付', numeric: true), - DsColumn('net', '净额', numeric: true), - DsColumn('actions', '操作', action: true), - ], - rows: rows.map((r) { - return DsRow( - onTap: () => showFinancePartnerDrawer(context, row: r), - cells: [ - Text(r.name, - style: - TextStyle(fontWeight: FontWeight.w600, color: t.heading)), - Text(r.recv > 0 ? _yuan(r.recv) : '—', - style: const TextStyle( - fontFamily: AppFonts.mono, - fontFamilyFallback: AppFonts.monoFallback)), - Text(r.pay > 0 ? _yuan(r.pay) : '—', - style: const TextStyle( - fontFamily: AppFonts.mono, - fontFamilyFallback: AppFonts.monoFallback)), - Text(signedYuan(r.net), + ), + ) + .toList(), + columns: const [ + DsColumn('name', '往来单位'), + DsColumn('recv', '应收', numeric: true), + DsColumn('pay', '应付', numeric: true), + DsColumn('net', '净额', numeric: true), + DsColumn('actions', '操作', action: true), + ], + rows: rows.map((r) { + return DsRow( + onTap: () => showFinancePartnerDrawer(context, row: r), + cells: [ + Text( + r.name, style: TextStyle( - fontFamily: AppFonts.mono, - fontFamilyFallback: AppFonts.monoFallback, - fontWeight: FontWeight.w600, - color: r.net > 0 - ? t.success - : (r.net < 0 ? t.danger : t.text))), - InkWell( - onTap: () => showFinancePartnerDrawer(context, row: r), - child: Text('查看', + fontWeight: FontWeight.w600, + color: t.heading, + ), + ), + Text( + r.recv > 0 ? _yuan(r.recv) : '—', + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + ), + ), + Text( + r.pay > 0 ? _yuan(r.pay) : '—', + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + ), + ), + Text( + signedYuan(r.net), + style: TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w600, + color: r.net > 0 + ? t.success + : (r.net < 0 ? t.danger : t.text), + ), + ), + InkWell( + onTap: () => showFinancePartnerDrawer(context, row: r), + child: Text( + '查看', style: TextStyle( - fontSize: AppDims.fsBody, - fontWeight: FontWeight.w600, - color: t.primary)), - ), - ], - ); - }).toList(), - ), + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + color: t.primary, + ), + ), + ), + ], + ); + }).toList(), + ); + }, ); } @@ -605,47 +725,65 @@ class _FinanceScreenState extends ConsumerState { // ── 收支流水表 ── Widget _flowTable( - dynamic t, bool mobile, List flows, int total) { + dynamic t, + bool mobile, + List flows, + int total, + ) { final chips = [ for (final c in _flowChips) ...[ DsChip( - label: c, - selected: _flowChip == c, - caret: false, - onTap: () => _setFlowChip(c)), + label: c, + selected: _flowChip == c, + caret: false, + onTap: () => _setFlowChip(c), + ), const SizedBox(width: 10), ], ]; final toolbar = mobile ? SingleChildScrollView( - scrollDirection: Axis.horizontal, child: Row(children: chips)) - : Row(children: [ - Text('类型', - style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), - const SizedBox(width: 12), - ...chips, - const SizedBox(width: 10), - // 往来单位:可搜索单选下拉(→ financeListProvider.setPartner;照 - // stock_out_list_screen.dart 客户 ComboSearchField 写法)。仅桌面 - // 工具栏放置,移动端对应筛选在 _timeRow 尾部的「往来单位」chip → sheet。 - ComboSearchField( - options: (ref.watch(allPartnersProvider).valueOrNull?.data ?? - const []) - .map((p) => OptionItem(id: p.id, name: p.name, code: p.code)) - .toList(), - selectedId: _partnerId, - hint: '往来单位', - width: 180, - onChanged: _setPartner, - ), - const Spacer(), - Text('共 $total 条', + scrollDirection: Axis.horizontal, + child: Row(children: chips), + ) + : Row( + children: [ + Text( + '类型', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted), + ), + const SizedBox(width: 12), + ...chips, + const SizedBox(width: 10), + // 往来单位:可搜索单选下拉(→ financeListProvider.setPartner;照 + // stock_out_list_screen.dart 客户 ComboSearchField 写法)。仅桌面 + // 工具栏放置,移动端对应筛选在 _timeRow 尾部的「往来单位」chip → sheet。 + ComboSearchField( + options: + (ref.watch(allPartnersProvider).valueOrNull?.data ?? + const []) + .map( + (p) => + OptionItem(id: p.id, name: p.name, code: p.code), + ) + .toList(), + selectedId: _partnerId, + hint: '往来单位', + width: 180, + onChanged: _setPartner, + ), + const Spacer(), + Text( + '共 $total 条', style: TextStyle( - fontSize: AppDims.fsSm, - color: t.muted, - fontFamily: AppFonts.mono, - fontFamilyFallback: AppFonts.monoFallback)), - ]); + fontSize: AppDims.fsSm, + color: t.muted, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + ), + ), + ], + ); return DsTable( shrinkWrap: true, @@ -669,36 +807,48 @@ class _FinanceScreenState extends ConsumerState { ], rows: flows.map((r) { final isIn = r.type == 'receipt' || r.type == 'receivable'; - return DsRow(cells: [ - Text((r.recordDate ?? '').split('T').first, + return DsRow( + cells: [ + Text( + (r.recordDate ?? '').split('T').first, style: const TextStyle( - fontFamily: AppFonts.mono, - fontFamilyFallback: AppFonts.monoFallback)), - financeTypeBadge(r), - Text((r.partnerName?.isNotEmpty == true) ? r.partnerName! : '—'), - // 原型 .qty:+/- 前缀、恒 heading 色(不上正负色) - Text('${isIn ? '+' : '-'}${_yuan(r.amount.abs())}', + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + ), + ), + financeTypeBadge(r), + Text((r.partnerName?.isNotEmpty == true) ? r.partnerName! : '—'), + // 原型 .qty:+/- 前缀、恒 heading 色(不上正负色) + Text( + '${isIn ? '+' : '-'}${_yuan(r.amount.abs())}', style: TextStyle( - fontFamily: AppFonts.mono, - fontFamilyFallback: AppFonts.monoFallback, - fontWeight: FontWeight.w600, - color: t.heading)), - Text( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w600, + color: t.heading, + ), + ), + Text( r.refType != null && r.refId != null ? '${r.docTitle} #${r.refId}' : '—', style: TextStyle( - fontFamily: AppFonts.mono, - fontFamilyFallback: AppFonts.monoFallback, - fontSize: AppDims.fsSm, - color: t.muted)), - Text((r.remark?.isNotEmpty == true) ? r.remark! : '—', - style: TextStyle(color: t.muted)), - r.status == 'open' - ? const DsBadge('未结清', tone: DsBadgeTone.warn) - : const DsBadge('已结清', tone: DsBadgeTone.muted), - _closeAction(t, r), - ]); + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontSize: AppDims.fsSm, + color: t.muted, + ), + ), + Text( + (r.remark?.isNotEmpty == true) ? r.remark! : '—', + style: TextStyle(color: t.muted), + ), + r.status == 'open' + ? const DsBadge('未结清', tone: DsBadgeTone.warn) + : const DsBadge('已结清', tone: DsBadgeTone.muted), + _closeAction(t, r), + ], + ); }).toList(), ); } @@ -715,11 +865,14 @@ class _FinanceScreenState extends ConsumerState { return WriteGuard( child: InkWell( onTap: () => _confirmClose(r), - child: Text('结清', - style: TextStyle( - fontSize: AppDims.fsBody, - fontWeight: FontWeight.w600, - color: t.primary)), + child: Text( + '结清', + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + color: t.primary, + ), + ), ), ); } @@ -729,13 +882,17 @@ class _FinanceScreenState extends ConsumerState { context: context, builder: (ctx) => AlertDialog( title: const Text('确认结清'), - content: Text('确认将「${r.partnerName ?? ''} ${r.typeLabel} ' - '${_yuan(r.amount.abs())}」标记为已结清?'), + content: Text( + '确认将「${r.partnerName ?? ''} ${r.typeLabel} ' + '${_yuan(r.amount.abs())}」标记为已结清?', + ), actions: [ DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)), - DsButton('结清', - variant: DsBtnVariant.primary, - onPressed: () => Navigator.of(ctx).pop(true)), + DsButton( + '结清', + variant: DsBtnVariant.primary, + onPressed: () => Navigator.of(ctx).pop(true), + ), ], ), ); @@ -758,7 +915,8 @@ class _FinanceScreenState extends ConsumerState { final isIn = r.type == 'receipt' || r.type == 'receivable'; return MobileListCard( title: Text( - (r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel), + (r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel, + ), subtitle: Text((r.recordDate ?? '').split('T').first), trailing: financeTypeBadge(r), fields: [ @@ -766,7 +924,8 @@ class _FinanceScreenState extends ConsumerState { MobileCardField('状态', r.status == 'open' ? '未结清' : '已结清'), if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark), ], - actions: (r.status == 'open' && + actions: + (r.status == 'open' && (r.type == 'receivable' || r.type == 'payable')) ? [ WriteGuard( @@ -784,22 +943,23 @@ class _FinanceScreenState extends ConsumerState { /// delta 箭头/色调映射(与 DsKpi 同规则:▲/▼ 文本字符)。 String _mDeltaText(String text, DsKpiDelta tone) => switch (tone) { - DsKpiDelta.up => '▲ $text', - DsKpiDelta.down => '▼ $text', - DsKpiDelta.neutral => text, - }; + 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, - }; + 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 ?? + final rows = + ref.watch(financePartnerRowsProvider).valueOrNull ?? const []; var recv = 0.0, pay = 0.0, openCount = 0; for (final r in rows) { @@ -813,58 +973,66 @@ class _FinanceScreenState extends ConsumerState { 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, - ), - ]); + 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))), - ); + 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([String msg = '暂无数据']) => Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Text( + msg, + 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], - ], - ]; + for (var i = 0; i < cards.length; i++) ...[ + if (i > 0) const SizedBox(height: 10), + cards[i], + ], + ]; if (_mSeg == 2) { return [ @@ -879,27 +1047,32 @@ class _FinanceScreenState extends ConsumerState { return async.when( loading: () => [ const Padding( - padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator())), + 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))), + 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(); + final list = _filterSummaryRows( + rows.where((r) => ar ? r.recv > 0 : r.pay > 0).toList(), + ); return [ section(ar ? '应收账款' : '应付账款', list.length), if (list.isEmpty) - empty() + empty(_summaryKw.trim().isEmpty ? '暂无数据' : '没有匹配的往来单位') else - ...stacked( - [for (final r in list) _mPartnerCard(t, r, ar: ar)]), + ...stacked([for (final r in list) _mPartnerCard(t, r, ar: ar)]), ]; }, ); @@ -914,16 +1087,18 @@ class _FinanceScreenState extends ConsumerState { trailing: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - DsBadge('未结清', - tone: DsBadgeTone.warn, icon: statusIcon('未结清')), + 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)), + Text( + _yuan(ar ? r.recv : r.pay), + style: TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w600, + fontSize: 13, + color: t.heading, + ), + ), ], ), ); @@ -934,27 +1109,33 @@ class _FinanceScreenState extends ConsumerState { final isIn = r.type == 'receipt' || r.type == 'receivable'; return MobileListCard( title: Text( - (r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel), + (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)), + 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)), + 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, + ), + ), ], ), ); @@ -988,31 +1169,34 @@ class _PartnerSheetBodyState extends State<_PartnerSheetBody> { required String name, required bool selected, required VoidCallback onTap, - }) => - InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 4), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: t.borderSubtle)), - ), - child: Row(children: [ - Expanded( - child: Text(name, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: AppDims.fsBody, - fontWeight: - selected ? FontWeight.w600 : FontWeight.w400, - color: selected ? t.primary : t.text)), + }) => InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 4), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: t.borderSubtle)), + ), + child: Row( + children: [ + Expanded( + child: Text( + name, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? t.primary : t.text, + ), ), - if (selected) ...[ - const SizedBox(width: 8), - Icon(LucideIcons.check, size: 18, color: t.primary), - ], - ]), - ), - ); + ), + if (selected) ...[ + const SizedBox(width: 8), + Icon(LucideIcons.check, size: 18, color: t.primary), + ], + ], + ), + ), + ); return Column( mainAxisSize: MainAxisSize.min, @@ -1023,24 +1207,28 @@ class _PartnerSheetBodyState extends State<_PartnerSheetBody> { color: t.bg, borderRadius: BorderRadius.circular(AppDims.rMd), ), - child: Row(children: [ - Icon(LucideIcons.search, size: 15, color: t.faint), - const SizedBox(width: 8), - Expanded( - child: TextField( - style: TextStyle(fontSize: AppDims.fsBody, color: t.text), - decoration: InputDecoration( - isCollapsed: true, - contentPadding: EdgeInsets.zero, - border: InputBorder.none, - hintText: '搜索往来单位…', - hintStyle: - TextStyle(fontSize: AppDims.fsBody, color: t.faint), + child: Row( + children: [ + Icon(LucideIcons.search, size: 15, color: t.faint), + const SizedBox(width: 8), + Expanded( + child: TextField( + style: TextStyle(fontSize: AppDims.fsBody, color: t.text), + decoration: InputDecoration( + isCollapsed: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + hintText: '搜索往来单位…', + hintStyle: TextStyle( + fontSize: AppDims.fsBody, + color: t.faint, + ), + ), + onChanged: (v) => setState(() => _kw = v), ), - onChanged: (v) => setState(() => _kw = v), ), - ), - ]), + ], + ), ), const SizedBox(height: 6), optRow( @@ -1051,8 +1239,10 @@ class _PartnerSheetBodyState extends State<_PartnerSheetBody> { if (hits.isEmpty) Padding( padding: const EdgeInsets.all(20), - child: Text('无匹配结果', - style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)), + child: Text( + '无匹配结果', + style: TextStyle(color: t.muted, fontSize: AppDims.fsSm), + ), ) else for (final o in hits) diff --git a/client/test/finance_summary_search_test.dart b/client/test/finance_summary_search_test.dart new file mode 100644 index 0000000..ef7ec1a --- /dev/null +++ b/client/test/finance_summary_search_test.dart @@ -0,0 +1,194 @@ +// test/finance_summary_search_test.dart — 财务「应收/应付 汇总」往来单位搜索回归。 +// +// financePartnerRowsProvider(GET /finance/summary)不分页、一次性全量加载, +// 搜索口径=显示口径:仅需验证本地 contains 过滤在已加载行上生效——桌面 +// _summaryTable 工具头 DsSearchBox / 移动 应收·应付 分段头 MSearchRow 均覆盖: +// 1. 输入关键字 → 汇总行按往来单位名过滤,未命中不显示; +// 2. 关键字不匹配任何行 → 展示「没有匹配的往来单位」空态; +// 3. 清空关键字 → 恢复全量展示。 +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/api/api_client.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/core/models/page_result.dart'; +import 'package:jiu_client/core/theme/themes.dart'; +import 'package:jiu_client/models/finance.dart'; +import 'package:jiu_client/models/partner.dart'; +import 'package:jiu_client/models/stock_summary.dart'; +import 'package:jiu_client/providers/finance_provider.dart'; +import 'package:jiu_client/providers/partner_provider.dart'; +import 'package:jiu_client/providers/stock_in_provider.dart'; +import 'package:jiu_client/providers/stock_out_provider.dart'; +import 'package:jiu_client/repositories/finance_repository.dart'; +import 'package:jiu_client/screens/finance/finance_screen.dart'; +import 'package:jiu_client/widgets/ds/ds_atoms.dart' show DsSearchBox; +import 'package:jiu_client/widgets/ds/m_search_row.dart' show MSearchRow; + +/// 不打真实网络:仅用于满足 FinanceRepository 构造签名,方法全部被覆写。 +class _DummyApiClient extends ApiClient { + _DummyApiClient() : super(token: 'test-token'); +} + +/// 固定 3 条往来单位汇总(2 应收 + 1 应付),流水列表恒空(本测试不关心流水)。 +class _FakeFinanceRepository extends FinanceRepository { + _FakeFinanceRepository() : super(_DummyApiClient()); + + @override + Future> listRecords({ + String? type, + String? month, + String? startDate, + String? endDate, + int? partnerId, + int page = 1, + int pageSize = 50, + }) async => + const PageResult(data: [], total: 0, page: 1, pageSize: 50); + + @override + Future> partnerSummary() async => const [ + PartnerFinanceSummary( + partnerId: 1, + partnerName: '华东酒类批发', + type: 'receivable', + recordCount: 2, + totalAmount: 128400), + PartnerFinanceSummary( + partnerId: 2, + partnerName: '金樽商贸', + type: 'receivable', + recordCount: 1, + totalAmount: 86200), + PartnerFinanceSummary( + partnerId: 3, + partnerName: '茅台华南经销', + type: 'payable', + recordCount: 1, + totalAmount: 142800), + ]; + + @override + Future> trend({int months = 6}) async => const []; +} + +class _FakeAllPartners extends PartnerListNotifier { + @override + Future> build() async => const PageResult( + data: [ + Partner(id: 1, name: '华东酒类批发', type: 'customer'), + Partner(id: 2, name: '金樽商贸', type: 'customer'), + Partner(id: 3, name: '茅台华南经销', type: 'supplier'), + ], + total: 3, + page: 1, + pageSize: 1000, + ); +} + +List _overrides() => [ + financeRepositoryProvider.overrideWith((ref) => _FakeFinanceRepository()), + allPartnersProvider.overrideWith(() => _FakeAllPartners()), + stockOutSummaryProvider.overrideWith( + (ref) async => const StockSummary(monthAmount: 0, lastMonthAmount: 0)), + stockInSummaryProvider.overrideWith( + (ref) async => const StockSummary(monthAmount: 0, lastMonthAmount: 0)), + isReadonlyProvider.overrideWithValue(false), + ]; + +Future _pumpFinance( + WidgetTester tester, { + required Size size, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(ProviderScope( + overrides: _overrides(), + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: buildTheme('a'), + home: const Scaffold(body: FinanceScreen()), + ), + )); + await tester.pumpAndSettle(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + group('桌面汇总卡往来单位搜索', () { + testWidgets('输入关键字过滤汇总行,无匹配报空态,清空恢复全量', (tester) async { + await _pumpFinance(tester, size: const Size(1280, 900)); + + // 初始:3 条汇总行全部展示。 + expect(find.text('华东酒类批发'), findsOneWidget); + expect(find.text('金樽商贸'), findsOneWidget); + expect(find.text('茅台华南经销'), findsOneWidget); + + final searchField = find.descendant( + of: find.byType(DsSearchBox), + matching: find.byType(TextField), + ); + expect(searchField, findsOneWidget); + + // 输入「金樽」:只剩「金樽商贸」。 + await tester.enterText(searchField, '金樽'); + await tester.pumpAndSettle(); + expect(find.text('金樽商贸'), findsOneWidget); + expect(find.text('华东酒类批发'), findsNothing); + expect(find.text('茅台华南经销'), findsNothing); + + // 输入不存在的关键字:空态提示。 + await tester.enterText(searchField, '不存在的单位名'); + await tester.pumpAndSettle(); + expect(find.text('没有匹配的往来单位'), findsOneWidget); + expect(find.text('金樽商贸'), findsNothing); + + // 清空:恢复全量 3 行。 + await tester.enterText(searchField, ''); + await tester.pumpAndSettle(); + expect(find.text('华东酒类批发'), findsOneWidget); + expect(find.text('金樽商贸'), findsOneWidget); + expect(find.text('茅台华南经销'), findsOneWidget); + }); + }); + + group('移动应收/应付分段往来单位搜索', () { + testWidgets('应收分段输入关键字过滤卡片,清空恢复全量', (tester) async { + await _pumpFinance(tester, size: const Size(390, 1600)); + + // 默认「应收」分段:仅展示 recv>0 的两条(华东/金樽),不含应付的茅台。 + expect(find.text('华东酒类批发'), findsOneWidget); + expect(find.text('金樽商贸'), findsOneWidget); + expect(find.text('茅台华南经销'), findsNothing); + + final searchField = find.descendant( + of: find.byType(MSearchRow), + matching: find.byType(TextField), + ); + expect(searchField, findsOneWidget); + + await tester.enterText(searchField, '金樽'); + await tester.pumpAndSettle(); + expect(find.text('金樽商贸'), findsOneWidget); + expect(find.text('华东酒类批发'), findsNothing); + + // 无匹配 → 分段空态。 + await tester.enterText(searchField, '不存在的单位名'); + await tester.pumpAndSettle(); + expect(find.text('没有匹配的往来单位'), findsOneWidget); + + // 清空恢复。 + await tester.enterText(searchField, ''); + await tester.pumpAndSettle(); + expect(find.text('华东酒类批发'), findsOneWidget); + expect(find.text('金樽商贸'), findsOneWidget); + }); + }); +} diff --git a/client/test/golden/goldens/finance_a.png b/client/test/golden/goldens/finance_a.png index d398fe7..a3c901e 100644 Binary files a/client/test/golden/goldens/finance_a.png and b/client/test/golden/goldens/finance_a.png differ diff --git a/client/test/golden/goldens/finance_b.png b/client/test/golden/goldens/finance_b.png index a64fe4d..41115b1 100644 Binary files a/client/test/golden/goldens/finance_b.png and b/client/test/golden/goldens/finance_b.png differ diff --git a/client/test/golden/goldens/finance_c.png b/client/test/golden/goldens/finance_c.png index 12f8c0c..de3fa74 100644 Binary files a/client/test/golden/goldens/finance_c.png and b/client/test/golden/goldens/finance_c.png differ diff --git a/client/test/golden/goldens/finance_mobile_a.png b/client/test/golden/goldens/finance_mobile_a.png index 88918c4..61d32dd 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 98e4fac..08cee15 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 97e2ae6..a621e92 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/m_finance_a.png b/client/test/golden/goldens/m_finance_a.png index bb3e5a8..448f947 100644 Binary files a/client/test/golden/goldens/m_finance_a.png 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 index 0a81169..cbbe6db 100644 Binary files a/client/test/golden/goldens/m_finance_b.png 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 index 6e5ea7a..a0a469f 100644 Binary files a/client/test/golden/goldens/m_finance_c.png and b/client/test/golden/goldens/m_finance_c.png differ diff --git a/design/CONTRACT.md b/design/CONTRACT.md index 13ce9d3..925ef76 100644 --- a/design/CONTRACT.md +++ b/design/CONTRACT.md @@ -105,7 +105,7 @@ AppTokens 目前**仅颜色**。原型还驱动: | 基础数据(移动) | `m-products.html` | 同上窄屏(卡片流) | 同步 | ✅ golden 自比 | ✅ golden 自比 | ✅ golden 自比 | | 入库列表(桌/移) | `stock-in-list.html` | `stock_in_list_screen.dart` | 同步 | ✅ 副标 + 共享 StatusPill(草稿/待审/已审/已拒);**2026-07-14 六项优化**:详情抽屉明细增加生产日期+批次号两列(抽屉加宽 660)、状态筛选改多选 chip 且默认勾选「草稿+待审核」、新建单默认填充默认仓库(原型 stock-in-list.html/stock-in.js 同步);fidelity a 3.323% / b 2.319% / c 3.633%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ golden | ✅ golden | | 出库列表(桌/移) | `stock-out-list.html` | `stock_out_list_screen.dart` | 同步 | ✅ 副标 + 共享 StatusPill;**2026-07-14 六项优化**:详情抽屉明细同增生产日期+批次号两列(抽屉 660)、状态筛选改多选 chip 且默认「草稿+待审核」、新建单默认填充默认仓库(原型 stock-out-list.html 同步);fidelity a 3.228% / b 2.217% / c 3.538%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ golden | ✅ golden | -| 财务(桌/移) | `finance.html` | `finance_screen.dart` | 同步 | ✅ **Phase2 重建**:时间范围 chips(真实过滤)+KPI 4 卡(stock summary 环比)+收支趋势柱状图(DsBarChart, /finance/trend)+应收应付汇总(/finance/summary)+流水表+往来抽屉+登记收支(补往来单位);**2026-07-14 六项优化**:流水表筛选新增「往来单位」下拉、时间范围统一改用通用自定义时间组件(原型 finance.html/m-finance.html 同步);fidelity a 4.036% / b 3.131% / c 4.340%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ fidelity | ✅ fidelity | +| 财务(桌/移) | `finance.html` | `finance_screen.dart` | 同步 | ✅ **Phase2 重建**:时间范围 chips(真实过滤)+KPI 4 卡(stock summary 环比)+收支趋势柱状图(DsBarChart, /finance/trend)+应收应付汇总(/finance/summary)+流水表+往来抽屉+登记收支(补往来单位);**2026-07-14 六项优化**:流水表筛选新增「往来单位」下拉、时间范围统一改用通用自定义时间组件(原型 finance.html/m-finance.html 同步);fidelity a 4.036% / b 3.131% / c 4.340%(均 ≤ 阈值 6%),移动 golden 自比全绿;**2026-07-14 追加**:应收/应付汇总卡头部新增「往来单位」搜索——本地即时过滤(`/finance/summary` 全量非分页,搜索口径=显示口径),桌面 `DsSearchBox`(`_secHead` trailing)/移动 应收·应付 分段头 `MSearchRow`(流水分段沿用既有 chip→sheet 单选,不重复),原型 finance.html `.sum-search` / m-finance.html 应收应付分段 `.m-search` 同步,finance 相关 6 个整屏 golden + m_finance 3 个 golden 已按新布局 `--update-goldens` | ✅ fidelity | ✅ fidelity | | 设备管理(桌/移) | `devices.html` | `device_management_screen.dart` | 同步 | ✅ **Phase2 重建**:会话表(DsTable)+外设卡网格(custom_fields.peripherals 本地存档)+打印模板;fidelity 2.4–3.0%≤8% | ✅ fidelity | ✅ fidelity | | 系统设置(桌/移) | `settings.html` | `settings_screen.dart` | 同步 | ✅ **Phase2 重建**;假「系统参数」已删;fidelity 1.7–2.3%≤8%;**2026-07-10 授权管理面板迁出**(提升为独立一级屏 `license.html`),subnav 剩 门店信息/用户管理/偏好——**真实端已同步完成**(`settings_screen.dart` 子导航同步摘除授权管理项,golden 已按新态重生成,见授权管理两行) | ✅ fidelity | ✅ fidelity | | 用户管理(桌/移) | `users.html` | `users_screen.dart`(`/settings/users`) | 同步 | ✅ **Phase2 新独立页**:KPI 4 卡+搜索/角色筛选+rcard 弹窗,角色四级拉平;fidelity 1.3–2.1%≤8% | ✅ fidelity | ✅ fidelity | diff --git a/design/prototype/screens/finance.html b/design/prototype/screens/finance.html index f734d24..fb69a39 100644 --- a/design/prototype/screens/finance.html +++ b/design/prototype/screens/finance.html @@ -28,6 +28,8 @@ .sec-head{display:flex; align-items:baseline; gap:10px; margin:0 0 12px;} .sec-head h2{margin:0; font-size:var(--fs-h2); color:var(--heading); font-weight:700;} .sec-head .sh-sub{font-size:var(--fs-xs); color:var(--muted);} + /* 汇总卡头部往来单位搜索(即时本地过滤,非 combo 单选) */ + .sec-head .sum-search{margin-left:auto; width:220px; align-self:center;} .block{margin-bottom:22px;} /* ====== 新增原子:条形图 .barchart / .bar ====== */ @@ -108,7 +110,9 @@
-

应收 / 应付 汇总

按往来单位 · 点击行查看流水
+

应收 / 应付 汇总

按往来单位 · 点击行查看流水 + +
@@ -181,19 +185,23 @@ const SUMMARY=[ {name:'川渝糖酒集团',recv:0,pay:60400} ]; function yuan(n){ return '¥'+n.toLocaleString(); } +// 汇总卡「往来单位」搜索:本地即时过滤(数据已全量加载,非服务端分页,搜索口径=显示口径) +let sumKw=''; function buildSummary(){ - document.getElementById('sumBody').innerHTML=SUMMARY.map((s,i)=>{ + const q=sumKw.trim().toLowerCase(); + const rows=SUMMARY.map((s,i)=>({...s,i})).filter(s=>!q||s.name.toLowerCase().includes(q)); + document.getElementById('sumBody').innerHTML=rows.length?rows.map(s=>{ const net=s.recv-s.pay; const netCls=net>0?'net-pos':(net<0?'net-neg':''); const netTxt=(net>0?'+':'')+yuan(net); - return ` + return ` - + `; - }).join(''); + }).join(''):``; } /* ---- 5. 收支流水 ---- */ diff --git a/design/prototype/screens/m-finance.html b/design/prototype/screens/m-finance.html index b36d612..923ffe4 100644 --- a/design/prototype/screens/m-finance.html +++ b/design/prototype/screens/m-finance.html @@ -38,6 +38,9 @@
应付合计
¥9.6万
5 笔未结
+ +
应收账款
@@ -100,15 +103,22 @@ function drawPartnerOpts(kw){ } function setFlowParty(v){ flowParty=v; closeSheet(); render(); } +// 应收/应付分段「往来单位」搜索:本地即时过滤(数据已全量加载,搜索口径=显示口径) +let sumKw=''; function render(){ const cfg={ar:['应收账款',AR],ap:['应付账款',AP],flow:['资金流水',FLOW]}[tab]; // 往来单位筛选仅作用于「流水」分段(对应真实端 financeListProvider.setPartner 只过滤流水表) - const rows=tab==='flow'?cfg[1].filter(r=>flowParty==='全部'||r.party.split(' · ')[0]===flowParty):cfg[1]; + let rows=tab==='flow'?cfg[1].filter(r=>flowParty==='全部'||r.party.split(' · ')[0]===flowParty):cfg[1]; + document.getElementById('sumSearchRow').style.display=tab==='flow'?'none':'flex'; + if(tab!=='flow'){ + const q=sumKw.trim().toLowerCase(); + if(q) rows=rows.filter(r=>r.party.toLowerCase().includes(q)); + } document.getElementById('cnt').textContent=`${cfg[0]} · 共 ${rows.length}`; - document.getElementById('list').innerHTML=rows.map(r=>`
+ document.getElementById('list').innerHTML=rows.length?rows.map(r=>`
${r.party}
${r.date}
${r.status}${r.amt}
-
`).join(''); +
`).join(''):`
${tab==='flow'?'暂无数据':'没有匹配的往来单位'}
`; document.getElementById('partyVal').textContent=flowParty==='全部'?'':flowParty; document.getElementById('chipParty').classList.toggle('on',flowParty!=='全部'); }
往来单位应收应付净额操作
${s.name} ${s.recv?yuan(s.recv):'—'} ${s.pay?yuan(s.pay):'—'} ${netTxt}查看查看
没有匹配的往来单位