diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index 5e1ff35..b5a22fe 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -58,9 +58,13 @@ class _FinanceScreenState extends ConsumerState { // 窄屏三分段(原型 m-finance .seg:应收 / 应付 / 流水) int _mSeg = 0; // 应收/应付汇总卡「往来单位」搜索(本地即时过滤——financePartnerRowsProvider - // 全量加载非分页,搜索口径=显示口径;桌面 DsSearchBox / 移动 MSearchRow 共用) + // 全量加载非分页,搜索口径=显示口径;移动 应收·应付 分段头 MSearchRow 沿用) final _summaryKwCtrl = TextEditingController(); String _summaryKw = ''; + // 桌面汇总表表头「往来单位」下拉筛选(验收反馈改版:由卡标题行文字搜索 + // 改为表头内嵌 ComboSearchField,同 _flowTable 往来单位筛选控件;单选精确 + // 匹配一行,× 清除恢复全量;候选=当前汇总行往来单位,口径=显示口径)。 + int? _summaryPartnerId; static const _flowChips = ['全部', '应收', '应付', '收款', '付款']; static const _chipToType = { @@ -129,13 +133,30 @@ class _FinanceScreenState extends ConsumerState { void _setSummaryKw(String kw) => setState(() => _summaryKw = kw); - /// 汇总行本地过滤(往来单位名 contains,大小写不敏感)。 + /// 汇总行本地过滤(往来单位名 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(); } + void _setSummaryPartner(int? id) => setState(() => _summaryPartnerId = id); + + /// 桌面汇总表头下拉筛选:选中 → 精确匹配单行;未选 → 原样返回(专用)。 + /// [id] 优先取 partnerId,缺失(自由文本往来单位)时用行下标取负数兜底, + /// 与 header 里构建 ComboSearchField options 的合成 id 规则保持一致。 + List _filterSummaryByPartner( + List rows, + ) { + if (_summaryPartnerId == null) return rows; + for (var i = 0; i < rows.length; i++) { + if ((rows[i].partnerId ?? -(i + 1)) == _summaryPartnerId) { + return [rows[i]]; + } + } + return const []; + } + @override void dispose() { _summaryKwCtrl.dispose(); @@ -242,17 +263,7 @@ class _FinanceScreenState extends ConsumerState { _secHead(t, '收支趋势', '近 6 个月 · 单位 万元'), _trendChart(), const SizedBox(height: 22), - _secHead( - t, - '应收 / 应付 汇总', - '按往来单位 · 点击行查看流水', - trailing: DsSearchBox( - controller: _summaryKwCtrl, - hint: '搜索往来单位', - width: 220, - onChanged: _setSummaryKw, - ), - ), + _secHead(t, '应收 / 应付 汇总', '按往来单位 · 点击行查看流水'), _summaryTable(t, mobile), const SizedBox(height: 22), _secHead(t, '收支流水', _flowSub(flows)), @@ -531,7 +542,7 @@ class _FinanceScreenState extends ConsumerState { } /// 原型 .sec-head:h2 17/700 + 副标 fs-xs muted,下距 12。 - /// [trailing] 靠右附加控件(原型 .sec-head .sum-search,如汇总卡的往来单位搜索)。 + /// [trailing] 靠右附加控件(当前各段落调用均未传入,保留供后续段落复用)。 Widget _secHead(dynamic t, String title, String sub, {Widget? trailing}) => Padding( padding: const EdgeInsets.only(bottom: 12), @@ -628,10 +639,20 @@ class _FinanceScreenState extends ConsumerState { ], ), data: (allRows) { - final rows = _filterSummaryRows(allRows); + final rows = _filterSummaryByPartner(allRows); + // 下拉候选=当前汇总行往来单位(口径=显示口径,不用全量 partners,避免 + // 选到无余额单位出空表);id 合成规则同 _filterSummaryByPartner。 + final partnerOptions = [ + for (var i = 0; i < allRows.length; i++) + OptionItem( + id: allRows[i].partnerId ?? -(i + 1), + name: allRows[i].name, + ), + ]; return DsTable( shrinkWrap: true, - emptyText: _summaryKw.trim().isEmpty ? '暂无未结清的应收 / 应付' : '没有匹配的往来单位', + emptyText: + _summaryPartnerId == null ? '暂无未结清的应收 / 应付' : '没有匹配的往来单位', mobileCards: rows .map( (r) => MobileListCard( @@ -655,12 +676,38 @@ class _FinanceScreenState extends ConsumerState { ), ) .toList(), - columns: const [ - DsColumn('name', '往来单位'), - DsColumn('recv', '应收', numeric: true), - DsColumn('pay', '应付', numeric: true), - DsColumn('net', '净额', numeric: true), - DsColumn('actions', '操作', action: true), + columns: [ + DsColumn( + 'name', + '往来单位', + // 表头「往来单位」文字右边内嵌下拉(验收反馈:由卡标题行搜索框 + // 改到此处,与 _flowTable 往来单位筛选同款 ComboSearchField)。 + header: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '往来单位', + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w600, + color: t.muted, + ), + ), + const SizedBox(width: 10), + ComboSearchField( + options: partnerOptions, + selectedId: _summaryPartnerId, + hint: '搜索往来单位', + width: 180, + onChanged: _setSummaryPartner, + ), + ], + ), + ), + const DsColumn('recv', '应收', numeric: true), + const DsColumn('pay', '应付', numeric: true), + const DsColumn('net', '净额', numeric: true), + const DsColumn('actions', '操作', action: true), ], rows: rows.map((r) { return DsRow( diff --git a/client/test/finance_summary_search_test.dart b/client/test/finance_summary_search_test.dart index ef7ec1a..a80d681 100644 --- a/client/test/finance_summary_search_test.dart +++ b/client/test/finance_summary_search_test.dart @@ -1,14 +1,17 @@ -// test/finance_summary_search_test.dart — 财务「应收/应付 汇总」往来单位搜索回归。 +// test/finance_summary_search_test.dart — 财务「应收/应付 汇总」往来单位筛选回归。 // // financePartnerRowsProvider(GET /finance/summary)不分页、一次性全量加载, -// 搜索口径=显示口径:仅需验证本地 contains 过滤在已加载行上生效——桌面 -// _summaryTable 工具头 DsSearchBox / 移动 应收·应付 分段头 MSearchRow 均覆盖: -// 1. 输入关键字 → 汇总行按往来单位名过滤,未命中不显示; +// 搜索口径=显示口径: +// 桌面(验收反馈改版):表头「往来单位」内嵌 ComboSearchField(与 _flowTable +// 往来单位筛选同款),单选精确匹配 → 汇总只显示该行;× 清除 → 恢复全量。 +// 移动 应收·应付 分段头仍是 MSearchRow 本地 contains 过滤,未改动: +// 1. 输入关键字 → 汇总卡片按往来单位名过滤,未命中不显示; // 2. 关键字不匹配任何行 → 展示「没有匹配的往来单位」空态; // 3. 清空关键字 → 恢复全量展示。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:jiu_client/core/api/api_client.dart'; @@ -24,7 +27,7 @@ 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/combo_search_field.dart'; import 'package:jiu_client/widgets/ds/m_search_row.dart' show MSearchRow; /// 不打真实网络:仅用于满足 FinanceRepository 构造签名,方法全部被覆写。 @@ -122,8 +125,8 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); SharedPreferences.setMockInitialValues({}); - group('桌面汇总卡往来单位搜索', () { - testWidgets('输入关键字过滤汇总行,无匹配报空态,清空恢复全量', (tester) async { + group('桌面汇总表头往来单位下拉筛选', () { + testWidgets('选中往来单位只显示该行,× 清除恢复全量', (tester) async { await _pumpFinance(tester, size: const Size(1280, 900)); // 初始:3 条汇总行全部展示。 @@ -131,28 +134,36 @@ void main() { expect(find.text('金樽商贸'), findsOneWidget); expect(find.text('茅台华南经销'), findsOneWidget); - final searchField = find.descendant( - of: find.byType(DsSearchBox), - matching: find.byType(TextField), + // 定位表头内嵌的 ComboSearchField(按构造参数 hint 匹配,避免与 + // _flowTable toolbar 的往来单位 ComboSearchField(hint:'往来单位') + // 混淆;选中后触发器文案会变成往来单位名,故不能用显示文字定位)。 + final combo = find.byWidgetPredicate( + (w) => w is ComboSearchField && w.hint == '搜索往来单位', ); - expect(searchField, findsOneWidget); - - // 输入「金樽」:只剩「金樽商贸」。 - await tester.enterText(searchField, '金樽'); + expect(combo, findsOneWidget); + await tester.ensureVisible(combo); await tester.pumpAndSettle(); - expect(find.text('金樽商贸'), findsOneWidget); + + // 打开弹层,选中「金樽商贸」。 + await tester.tap(combo); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(InkWell, '金樽商贸')); + await tester.pumpAndSettle(); + + // 只剩「金樽商贸」一行。 expect(find.text('华东酒类批发'), findsNothing); expect(find.text('茅台华南经销'), findsNothing); + expect(find.text('金樽商贸'), findsWidgets); - // 输入不存在的关键字:空态提示。 - await tester.enterText(searchField, '不存在的单位名'); + // 点触发器上的 × 清除选择 → 恢复全量 3 行。 + final clearIcon = find.descendant( + of: combo, + matching: find.byIcon(LucideIcons.x), + ); + expect(clearIcon, findsOneWidget); + await tester.tap(clearIcon); 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); diff --git a/client/test/golden/goldens/finance_a.png b/client/test/golden/goldens/finance_a.png index a3c901e..9812ceb 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 41115b1..cb403ae 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 de3fa74..4506cca 100644 Binary files a/client/test/golden/goldens/finance_c.png and b/client/test/golden/goldens/finance_c.png differ diff --git a/design/CONTRACT.md b/design/CONTRACT.md index 925ef76..1939831 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 自比全绿;**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 | +| 财务(桌/移) | `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`;**2026-07-14 验收反馈调整**:桌面「往来单位」搜索由卡标题行 `DsSearchBox` 挪进汇总表表头「往来单位」列头右侧,改为下拉单选 `ComboSearchField`(同 `_flowTable` 往来单位筛选控件,选中精确匹配一行/× 清除恢复全量,候选=当前汇总行往来单位);原型 `.sec-head .sum-search` 移除,改 `.th-party-wrap` 内嵌 `.combo` + `openSearchMenu` 弹层;移动端未动。desktop `finance` golden 3 主题已 `--update-goldens`(3.11% 差异,目检确认新版式),`finance_mobile`/`m_finance` 无变化、原样通过 | ✅ 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 fb69a39..fda3c0a 100644 --- a/design/prototype/screens/finance.html +++ b/design/prototype/screens/finance.html @@ -28,9 +28,11 @@ .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;} + /* 汇总表「往来单位」列头内嵌搜索下拉(对齐 ComboSearchField:combo 原子 + + openSearchMenu 弹层,单选精确匹配一行,非关键字子串过滤) */ + .th-party-wrap{display:flex; align-items:center; gap:10px;} + .th-party-wrap .combo{height:34px; width:180px; flex:none;} /* ====== 新增原子:条形图 .barchart / .bar ====== */ .chart-card{background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:18px 20px 14px; transition:background-color .28s,border-color .28s;} @@ -110,11 +112,12 @@
-

应收 / 应付 汇总

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

应收 / 应付 汇总

按往来单位 · 点击行查看流水
- + + + +
往来单位应收应付净额操作
往来单位
应收应付净额操作
@@ -185,11 +188,27 @@ const SUMMARY=[ {name:'川渝糖酒集团',recv:0,pay:60400} ]; function yuan(n){ return '¥'+n.toLocaleString(); } -// 汇总卡「往来单位」搜索:本地即时过滤(数据已全量加载,非服务端分页,搜索口径=显示口径) -let sumKw=''; +// 汇总表头「往来单位」下拉筛选(对齐 ComboSearchField:单选精确匹配一行,非 +// 关键字子串过滤;候选=当前汇总行往来单位,搜索口径=显示口径,不用全量往来 +// 单位库,避免选到无余额单位出空表) +let sumPartyId=null; // 选中的 SUMMARY 下标;null=全部 +function openSumPartyFilter(t){ + openSearchMenu(t, SUMMARY.map((s,i)=>({v:i,label:s.name,sel:i===sumPartyId})), v=>{ + sumPartyId=Number(v); closeMenus(); buildSummary(); + }, {placeholder:'搜索往来单位…'}); +} +// combo 已选中值(.has)时,点击右侧图标(此时为 ×)清除,不弹出下拉 +function sumPartyCafClick(e){ + if(sumPartyId!=null){ e.stopPropagation(); sumPartyId=null; buildSummary(); } +} function buildSummary(){ - const q=sumKw.trim().toLowerCase(); - const rows=SUMMARY.map((s,i)=>({...s,i})).filter(s=>!q||s.name.toLowerCase().includes(q)); + const rows=sumPartyId==null?SUMMARY.map((s,i)=>({...s,i})):SUMMARY.map((s,i)=>({...s,i})).filter(s=>s.i===sumPartyId); + const has=sumPartyId!=null; + document.getElementById('sumPartyCombo').classList.toggle('has',has); + document.getElementById('sumPartyInput').value=has?SUMMARY[sumPartyId].name:''; + document.getElementById('sumPartyCaf').innerHTML=has + ?'' + :''; 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':'');