From 1dd21e6df146c33cb3dd840253fbd6c708ce6aa0 Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Sat, 5 Sep 2026 20:49:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=A8=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=88=97=E8=A1=A8=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E4=B8=8E=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/user_app/lib/app/router.dart | 7 +- .../lib/ui/features/orders/orders_page.dart | 104 +++++++++++++----- .../ui/features/shared/record_list_page.dart | 38 +++++-- .../shared/record_list_view_model.dart | 31 +++++- apps/user_app/test/ui/orders_page_test.dart | 97 ++++++++++++++++ .../test/ui/record_list_page_test.dart | 45 ++++++++ .../test/ui/record_list_view_model_test.dart | 52 +++++++++ .../开发日志_订单列表生命周期修复_20260905.md | 47 ++++++++ 8 files changed, 378 insertions(+), 43 deletions(-) create mode 100644 apps/user_app/test/ui/orders_page_test.dart create mode 100644 apps/user_app/test/ui/record_list_page_test.dart create mode 100644 apps/user_app/test/ui/record_list_view_model_test.dart create mode 100644 docs/开发日志_订单列表生命周期修复_20260905.md diff --git a/apps/user_app/lib/app/router.dart b/apps/user_app/lib/app/router.dart index 8781bab..38e62c0 100644 --- a/apps/user_app/lib/app/router.dart +++ b/apps/user_app/lib/app/router.dart @@ -9,7 +9,6 @@ import '../ui/features/home/home_page.dart'; import '../ui/features/orders/orders_page.dart'; import '../ui/features/profile/profile_page.dart'; import '../ui/features/shared/record_list_page.dart'; -import '../ui/features/shared/record_list_view_model.dart'; import '../ui/features/shop/shop_page.dart'; import 'auth_navigation.dart'; import 'dependencies.dart'; @@ -56,7 +55,8 @@ GoRouter createRouter( builder: (context, state) => RecordListPage( title: '供气合同', eyebrow: '可信履约', - viewModel: RecordListViewModel(dependencies.repository.contracts), + sourceKey: 'contracts', + loader: dependencies.repository.contracts, ), ), GoRoute( @@ -64,7 +64,8 @@ GoRouter createRouter( builder: (context, state) => RecordListPage( title: '钱包流水', eyebrow: '资金记录', - viewModel: RecordListViewModel(dependencies.repository.walletRecords), + sourceKey: 'wallet_records', + loader: dependencies.repository.walletRecords, ), ), StatefulShellRoute.indexedStack( diff --git a/apps/user_app/lib/ui/features/orders/orders_page.dart b/apps/user_app/lib/ui/features/orders/orders_page.dart index f422860..e791b30 100644 --- a/apps/user_app/lib/ui/features/orders/orders_page.dart +++ b/apps/user_app/lib/ui/features/orders/orders_page.dart @@ -9,13 +9,21 @@ import '../../../data/services/api_client.dart'; import '../../../data/services/payment_launcher.dart'; import '../../../domain/models/client_models.dart'; import '../shared/record_list_page.dart'; -import '../shared/record_list_view_model.dart'; -class OrdersPage extends StatelessWidget { - OrdersPage({required this.repository, super.key}); +class OrdersPage extends StatefulWidget { + const OrdersPage({required this.repository, super.key}); final ClientRepository repository; + + @override + State createState() => _OrdersPageState(); +} + +class _OrdersPageState extends State { final PaymentLauncher _launcher = PaymentLauncher(); + int _shopRefreshToken = 0; + int _gasRefreshToken = 0; + int _refundRefreshToken = 0; Future _openActions( BuildContext context, @@ -61,7 +69,7 @@ class OrdersPage extends StatelessWidget { final reason = await _reason(context); if (reason == null || !context.mounted) return; final rawItems = record.raw['items'] as List? ?? const []; - await repository.createRefund( + await widget.repository.createRefund( business: business, identity: record.identity, requestNo: const Uuid().v7(), @@ -75,13 +83,21 @@ class OrdersPage extends StatelessWidget { }).toList(), ); if (context.mounted) { + setState(() { + if (business == 'shop') { + _shopRefreshToken += 1; + } else { + _gasRefreshToken += 1; + } + _refundRefreshToken += 1; + }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('退款申请已提交')), ); } return; } - final payment = await repository.payOrder( + final payment = await widget.repository.payOrder( business: business, identity: record.identity, requestNo: const Uuid().v7(), @@ -106,29 +122,10 @@ class OrdersPage extends StatelessWidget { } Future _reason(BuildContext context) async { - final controller = TextEditingController(); final value = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('申请退款'), - content: TextField( - controller: controller, - maxLines: 3, - decoration: const InputDecoration(labelText: '退款原因'), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消'), - ), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - child: const Text('提交'), - ), - ], - ), + builder: (context) => const _RefundReasonDialog(), ); - controller.dispose(); return value?.isEmpty == true ? null : value; } @@ -150,37 +147,86 @@ class OrdersPage extends StatelessWidget { body: TabBarView( children: [ RecordListPage( + key: const PageStorageKey('shop_orders'), title: '商城订单', eyebrow: '交易', description: '查看商城订单并处理待支付或退款事项', embedded: true, - viewModel: RecordListViewModel(repository.shopOrders), + sourceKey: 'shop_orders', + loader: widget.repository.shopOrders, + refreshToken: _shopRefreshToken, onRecordTap: (record) => _openActions(context, record, 'shop'), ), RecordListPage( + key: const PageStorageKey('gas_orders'), title: '供气订单', eyebrow: '履约', description: '查看供气订单与当前履约状态', embedded: true, - viewModel: RecordListViewModel(repository.gasOrders), + sourceKey: 'gas_orders', + loader: widget.repository.gasOrders, + refreshToken: _gasRefreshToken, onRecordTap: (record) => _openActions(context, record, 'gas'), ), RecordListPage( + key: const PageStorageKey('refunds'), title: '退款记录', eyebrow: '资金', description: '退款结果以服务端审核和资金流水为准', embedded: true, - viewModel: RecordListViewModel(repository.refunds), + sourceKey: 'refunds', + loader: widget.repository.refunds, + refreshToken: _refundRefreshToken, ), RecordListPage( + key: const PageStorageKey('tickets'), title: '服务工单', eyebrow: '服务', description: '查看维修、安装与其他服务工单', embedded: true, - viewModel: RecordListViewModel(repository.tickets), + sourceKey: 'tickets', + loader: widget.repository.tickets, ), ], ), ), ); } + +/// 独立管理退款原因输入框生命周期,避免关闭动画期间提前释放控制器。 +class _RefundReasonDialog extends StatefulWidget { + const _RefundReasonDialog(); + + @override + State<_RefundReasonDialog> createState() => _RefundReasonDialogState(); +} + +class _RefundReasonDialogState extends State<_RefundReasonDialog> { + final TextEditingController _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text('申请退款'), + content: TextField( + controller: _controller, + maxLines: 3, + decoration: const InputDecoration(labelText: '退款原因'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, _controller.text.trim()), + child: const Text('提交'), + ), + ], + ); +} diff --git a/apps/user_app/lib/ui/features/shared/record_list_page.dart b/apps/user_app/lib/ui/features/shared/record_list_page.dart index 6b40477..87dd390 100644 --- a/apps/user_app/lib/ui/features/shared/record_list_page.dart +++ b/apps/user_app/lib/ui/features/shared/record_list_page.dart @@ -8,45 +8,66 @@ class RecordListPage extends StatefulWidget { const RecordListPage({ required this.title, required this.eyebrow, - required this.viewModel, + required this.sourceKey, + required this.loader, this.description, this.floatingActionButton, this.onRecordTap, this.embedded = false, + this.refreshToken = 0, super.key, }); final String title; final String eyebrow; final String? description; - final RecordListViewModel viewModel; + final String sourceKey; + final RecordLoader loader; final Widget? floatingActionButton; final ValueChanged? onRecordTap; final bool embedded; + final int refreshToken; @override State createState() => _RecordListPageState(); } -class _RecordListPageState extends State { +class _RecordListPageState extends State + with AutomaticKeepAliveClientMixin { + late RecordListViewModel _viewModel; + @override void initState() { super.initState(); - widget.viewModel.load(); + _viewModel = RecordListViewModel(widget.loader)..load(); + } + + @override + void didUpdateWidget(covariant RecordListPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.sourceKey != oldWidget.sourceKey) { + _viewModel.dispose(); + _viewModel = RecordListViewModel(widget.loader)..load(); + return; + } + if (widget.refreshToken != oldWidget.refreshToken) { + _viewModel.load(); + } } @override void dispose() { - widget.viewModel.dispose(); + _viewModel.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + super.build(context); final content = ListenableBuilder( - listenable: widget.viewModel, + listenable: _viewModel, builder: (context, _) { - final state = widget.viewModel; + final state = _viewModel; if (state.loading && state.records.isEmpty) { return const Center(child: CircularProgressIndicator()); } @@ -123,4 +144,7 @@ class _RecordListPageState extends State { body: content, ); } + + @override + bool get wantKeepAlive => true; } diff --git a/apps/user_app/lib/ui/features/shared/record_list_view_model.dart b/apps/user_app/lib/ui/features/shared/record_list_view_model.dart index bdce6ae..ad186bc 100644 --- a/apps/user_app/lib/ui/features/shared/record_list_view_model.dart +++ b/apps/user_app/lib/ui/features/shared/record_list_view_model.dart @@ -14,23 +14,46 @@ class RecordListViewModel extends ChangeNotifier { List _records = const []; Object? _error; bool _loading = false; + bool _disposed = false; + int _requestGeneration = 0; List get records => List.unmodifiable(_records); Object? get error => _error; bool get loading => _loading; Future load() async { + final generation = ++_requestGeneration; _loading = true; _error = null; - notifyListeners(); + _notifyIfActive(); try { - _records = await _loader(); + final records = await _loader(); + if (!_isCurrent(generation)) return; + _records = records; } catch (error) { + if (!_isCurrent(generation)) return; // 会话失效由统一路由接管,不在列表中重复展示网络错误。 if (error is! SessionExpiredException) _error = error; } finally { - _loading = false; - notifyListeners(); + if (_isCurrent(generation)) { + _loading = false; + _notifyIfActive(); + } } } + + /// 仅允许最后一次请求且组件仍存活时写入状态。 + bool _isCurrent(int generation) => !_disposed && generation == _requestGeneration; + + /// 避免异步请求在页面销毁后继续通知监听器。 + void _notifyIfActive() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _requestGeneration += 1; + super.dispose(); + } } diff --git a/apps/user_app/test/ui/orders_page_test.dart b/apps/user_app/test/ui/orders_page_test.dart new file mode 100644 index 0000000..a638fe1 --- /dev/null +++ b/apps/user_app/test/ui/orders_page_test.dart @@ -0,0 +1,97 @@ +// 功能描述:验证订单退款成功后的跨标签刷新行为。 +// 版本:1.0.0 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/data/repositories/client_repository.dart'; +import 'package:user_app/data/services/api_client.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/ui/features/orders/orders_page.dart'; + +/// 提供无需网络的订单数据并记录各列表刷新次数。 +class _FakeRepository extends ClientRepository { + _FakeRepository() : super(ApiClient(() => '', baseUrl: 'https://api.example.com')); + + int shopCalls = 0; + int gasCalls = 0; + int refundCalls = 0; + int ticketCalls = 0; + int createRefundCalls = 0; + + @override + Future> shopOrders() async { + shopCalls += 1; + return const [ + ClientRecord( + identity: 'shop-1', + title: '商城订单一', + subtitle: '', + status: 18, + raw: { + 'items': [ + {'identity': 'item-1', 'quantity': 1}, + ], + }, + ), + ]; + } + + @override + Future> gasOrders() async { + gasCalls += 1; + return const []; + } + + @override + Future> refunds() async { + refundCalls += 1; + return const []; + } + + @override + Future> tickets() async { + ticketCalls += 1; + return const []; + } + + @override + Future createRefund({ + required String business, + required String identity, + required String requestNo, + required String reason, + required List> items, + }) async { + createRefundCalls += 1; + } +} + +void main() { + testWidgets('退款成功后刷新商城订单和退款记录', (tester) async { + final repository = _FakeRepository(); + await tester.pumpWidget( + MaterialApp(home: OrdersPage(repository: repository)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('退款')); + await tester.pumpAndSettle(); + expect(repository.refundCalls, 1); + + await tester.tap(find.text('商城')); + await tester.pumpAndSettle(); + expect(repository.shopCalls, 1); + + await tester.tap(find.text('商城订单一')); + await tester.pumpAndSettle(); + await tester.tap(find.text('申请退款')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '商品问题'); + await tester.tap(find.text('提交')); + await tester.pumpAndSettle(); + + expect(repository.createRefundCalls, 1); + expect(repository.shopCalls, 2); + expect(repository.refundCalls, 2); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/user_app/test/ui/record_list_page_test.dart b/apps/user_app/test/ui/record_list_page_test.dart new file mode 100644 index 0000000..be07561 --- /dev/null +++ b/apps/user_app/test/ui/record_list_page_test.dart @@ -0,0 +1,45 @@ +// 功能描述:验证记录列表在父组件重建和刷新标记变化时的生命周期。 +// 版本:1.0.0 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/ui/features/shared/record_list_page.dart'; + +void main() { + testWidgets('普通重建不重复请求,刷新标记变化才重新加载', (tester) async { + var calls = 0; + var refreshToken = 0; + late StateSetter rebuild; + Future> loader() async { + calls += 1; + return const []; + } + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return RecordListPage( + title: '测试列表', + eyebrow: '测试', + sourceKey: 'records', + loader: loader, + refreshToken: refreshToken, + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(calls, 1); + + rebuild(() {}); + await tester.pumpAndSettle(); + expect(calls, 1); + + rebuild(() => refreshToken += 1); + await tester.pumpAndSettle(); + expect(calls, 2); + }); +} diff --git a/apps/user_app/test/ui/record_list_view_model_test.dart b/apps/user_app/test/ui/record_list_view_model_test.dart new file mode 100644 index 0000000..155f909 --- /dev/null +++ b/apps/user_app/test/ui/record_list_view_model_test.dart @@ -0,0 +1,52 @@ +// 功能描述:验证记录列表异步请求的代次与销毁保护。 +// 版本:1.0.0 +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:user_app/domain/models/client_models.dart'; +import 'package:user_app/ui/features/shared/record_list_view_model.dart'; + +ClientRecord _record(String identity) => ClientRecord( + identity: identity, + title: identity, + subtitle: '', + raw: const {}, +); + +void main() { + test('后发请求优先,旧结果不会覆盖新数据', () async { + final first = Completer>(); + final second = Completer>(); + var calls = 0; + final viewModel = RecordListViewModel(() { + calls += 1; + return calls == 1 ? first.future : second.future; + }); + + final firstLoad = viewModel.load(); + final secondLoad = viewModel.load(); + second.complete([_record('new')]); + await secondLoad; + first.complete([_record('old')]); + await firstLoad; + + expect(viewModel.records.single.identity, 'new'); + expect(viewModel.loading, isFalse); + viewModel.dispose(); + }); + + test('销毁后异步结果不会继续通知监听器', () async { + final pending = Completer>(); + final viewModel = RecordListViewModel(() => pending.future); + var notifications = 0; + viewModel.addListener(() => notifications += 1); + + final load = viewModel.load(); + expect(notifications, 1); + viewModel.dispose(); + pending.complete([_record('late')]); + await load; + + expect(notifications, 1); + }); +} diff --git a/docs/开发日志_订单列表生命周期修复_20260905.md b/docs/开发日志_订单列表生命周期修复_20260905.md new file mode 100644 index 0000000..6a7f169 --- /dev/null +++ b/docs/开发日志_订单列表生命周期修复_20260905.md @@ -0,0 +1,47 @@ +# 订单列表生命周期修复操作日志 + +操作时间:2026-09-05 +操作类型:修改 +影响模块:用户端订单、退款、工单、合同及钱包流水列表 + +## 操作前状态 + +- 订单页在每次 `build()` 时创建四个新的列表 ViewModel。 +- 列表 State 仍监听旧对象,新对象未加载,旧异步请求可能在页面销毁后继续回调。 +- 用户连续刷新时,较早请求可能覆盖较新请求的数据。 +- 退款原因输入控制器在对话框退出动画结束前被释放,可能触发红屏。 +- 退款提交成功后,订单和退款列表不会同步刷新。 + +## 具体操作 + +- 将 ViewModel 的创建、首次加载和释放统一收归 `RecordListPage` State。 +- 通过稳定 `sourceKey` 识别数据源,通过 `refreshToken` 接收外部刷新命令。 +- 为四个订单标签增加稳定 Key 和 keep-alive,保留数据、错误状态及滚动位置。 +- ViewModel 增加请求代次和销毁保护,仅最后一次请求可更新状态。 +- 退款成功后刷新对应订单列表及退款列表。 +- 将退款输入控制器移入独立有状态对话框,随对话框生命周期安全释放。 +- 合同和钱包流水改用相同的公共列表生命周期契约。 + +## 代码变更 + +- `apps/user_app/lib/ui/features/shared/record_list_view_model.dart`:增加请求竞争和销毁保护。 +- `apps/user_app/lib/ui/features/shared/record_list_page.dart`:统一 ViewModel 所有权并支持稳定刷新。 +- `apps/user_app/lib/ui/features/orders/orders_page.dart`:稳定四个标签并联动退款刷新。 +- `apps/user_app/lib/app/router.dart`:迁移合同和钱包列表调用方式。 +- `apps/user_app/test/ui/record_list_view_model_test.dart`:覆盖旧请求和销毁边界。 +- `apps/user_app/test/ui/record_list_page_test.dart`:覆盖父组件重建及显式刷新。 +- `apps/user_app/test/ui/orders_page_test.dart`:覆盖退款成功后的跨列表刷新和红屏回归。 + +## 验证结果 + +- `flutter analyze`:通过,无静态分析问题。 +- 三组新增定向测试共 4 项全部通过。 +- `flutter test`:完整 33 项测试全部通过。 +- 用户端 Release Web 构建成功,本地静态服务 `http://127.0.0.1:5180/` 返回 HTTP 200。 +- 本地后端 `12426` 健康检查返回 HTTP 200。 + +## 风险评估 + +- 仅修改用户端内部组件接口,所有仓库内调用点已同步迁移。 +- HTTP 请求本身不强制取消,过期结果会被忽略,不再更新已销毁页面。 +- 支付发起后不提前假定支付成功,保持既有业务语义。