修复用户端订单列表生命周期与刷新
This commit is contained in:
@@ -9,7 +9,6 @@ import '../ui/features/home/home_page.dart';
|
|||||||
import '../ui/features/orders/orders_page.dart';
|
import '../ui/features/orders/orders_page.dart';
|
||||||
import '../ui/features/profile/profile_page.dart';
|
import '../ui/features/profile/profile_page.dart';
|
||||||
import '../ui/features/shared/record_list_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 '../ui/features/shop/shop_page.dart';
|
||||||
import 'auth_navigation.dart';
|
import 'auth_navigation.dart';
|
||||||
import 'dependencies.dart';
|
import 'dependencies.dart';
|
||||||
@@ -56,7 +55,8 @@ GoRouter createRouter(
|
|||||||
builder: (context, state) => RecordListPage(
|
builder: (context, state) => RecordListPage(
|
||||||
title: '供气合同',
|
title: '供气合同',
|
||||||
eyebrow: '可信履约',
|
eyebrow: '可信履约',
|
||||||
viewModel: RecordListViewModel(dependencies.repository.contracts),
|
sourceKey: 'contracts',
|
||||||
|
loader: dependencies.repository.contracts,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -64,7 +64,8 @@ GoRouter createRouter(
|
|||||||
builder: (context, state) => RecordListPage(
|
builder: (context, state) => RecordListPage(
|
||||||
title: '钱包流水',
|
title: '钱包流水',
|
||||||
eyebrow: '资金记录',
|
eyebrow: '资金记录',
|
||||||
viewModel: RecordListViewModel(dependencies.repository.walletRecords),
|
sourceKey: 'wallet_records',
|
||||||
|
loader: dependencies.repository.walletRecords,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
StatefulShellRoute.indexedStack(
|
StatefulShellRoute.indexedStack(
|
||||||
|
|||||||
@@ -9,13 +9,21 @@ import '../../../data/services/api_client.dart';
|
|||||||
import '../../../data/services/payment_launcher.dart';
|
import '../../../data/services/payment_launcher.dart';
|
||||||
import '../../../domain/models/client_models.dart';
|
import '../../../domain/models/client_models.dart';
|
||||||
import '../shared/record_list_page.dart';
|
import '../shared/record_list_page.dart';
|
||||||
import '../shared/record_list_view_model.dart';
|
|
||||||
|
|
||||||
class OrdersPage extends StatelessWidget {
|
class OrdersPage extends StatefulWidget {
|
||||||
OrdersPage({required this.repository, super.key});
|
const OrdersPage({required this.repository, super.key});
|
||||||
|
|
||||||
final ClientRepository repository;
|
final ClientRepository repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OrdersPage> createState() => _OrdersPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OrdersPageState extends State<OrdersPage> {
|
||||||
final PaymentLauncher _launcher = PaymentLauncher();
|
final PaymentLauncher _launcher = PaymentLauncher();
|
||||||
|
int _shopRefreshToken = 0;
|
||||||
|
int _gasRefreshToken = 0;
|
||||||
|
int _refundRefreshToken = 0;
|
||||||
|
|
||||||
Future<void> _openActions(
|
Future<void> _openActions(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
@@ -61,7 +69,7 @@ class OrdersPage extends StatelessWidget {
|
|||||||
final reason = await _reason(context);
|
final reason = await _reason(context);
|
||||||
if (reason == null || !context.mounted) return;
|
if (reason == null || !context.mounted) return;
|
||||||
final rawItems = record.raw['items'] as List? ?? const [];
|
final rawItems = record.raw['items'] as List? ?? const [];
|
||||||
await repository.createRefund(
|
await widget.repository.createRefund(
|
||||||
business: business,
|
business: business,
|
||||||
identity: record.identity,
|
identity: record.identity,
|
||||||
requestNo: const Uuid().v7(),
|
requestNo: const Uuid().v7(),
|
||||||
@@ -75,13 +83,21 @@ class OrdersPage extends StatelessWidget {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
setState(() {
|
||||||
|
if (business == 'shop') {
|
||||||
|
_shopRefreshToken += 1;
|
||||||
|
} else {
|
||||||
|
_gasRefreshToken += 1;
|
||||||
|
}
|
||||||
|
_refundRefreshToken += 1;
|
||||||
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('退款申请已提交')),
|
const SnackBar(content: Text('退款申请已提交')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final payment = await repository.payOrder(
|
final payment = await widget.repository.payOrder(
|
||||||
business: business,
|
business: business,
|
||||||
identity: record.identity,
|
identity: record.identity,
|
||||||
requestNo: const Uuid().v7(),
|
requestNo: const Uuid().v7(),
|
||||||
@@ -106,29 +122,10 @@ class OrdersPage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _reason(BuildContext context) async {
|
Future<String?> _reason(BuildContext context) async {
|
||||||
final controller = TextEditingController();
|
|
||||||
final value = await showDialog<String>(
|
final value = await showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => const _RefundReasonDialog(),
|
||||||
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('提交'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
controller.dispose();
|
|
||||||
return value?.isEmpty == true ? null : value;
|
return value?.isEmpty == true ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,37 +147,86 @@ class OrdersPage extends StatelessWidget {
|
|||||||
body: TabBarView(
|
body: TabBarView(
|
||||||
children: [
|
children: [
|
||||||
RecordListPage(
|
RecordListPage(
|
||||||
|
key: const PageStorageKey('shop_orders'),
|
||||||
title: '商城订单',
|
title: '商城订单',
|
||||||
eyebrow: '交易',
|
eyebrow: '交易',
|
||||||
description: '查看商城订单并处理待支付或退款事项',
|
description: '查看商城订单并处理待支付或退款事项',
|
||||||
embedded: true,
|
embedded: true,
|
||||||
viewModel: RecordListViewModel(repository.shopOrders),
|
sourceKey: 'shop_orders',
|
||||||
|
loader: widget.repository.shopOrders,
|
||||||
|
refreshToken: _shopRefreshToken,
|
||||||
onRecordTap: (record) => _openActions(context, record, 'shop'),
|
onRecordTap: (record) => _openActions(context, record, 'shop'),
|
||||||
),
|
),
|
||||||
RecordListPage(
|
RecordListPage(
|
||||||
|
key: const PageStorageKey('gas_orders'),
|
||||||
title: '供气订单',
|
title: '供气订单',
|
||||||
eyebrow: '履约',
|
eyebrow: '履约',
|
||||||
description: '查看供气订单与当前履约状态',
|
description: '查看供气订单与当前履约状态',
|
||||||
embedded: true,
|
embedded: true,
|
||||||
viewModel: RecordListViewModel(repository.gasOrders),
|
sourceKey: 'gas_orders',
|
||||||
|
loader: widget.repository.gasOrders,
|
||||||
|
refreshToken: _gasRefreshToken,
|
||||||
onRecordTap: (record) => _openActions(context, record, 'gas'),
|
onRecordTap: (record) => _openActions(context, record, 'gas'),
|
||||||
),
|
),
|
||||||
RecordListPage(
|
RecordListPage(
|
||||||
|
key: const PageStorageKey('refunds'),
|
||||||
title: '退款记录',
|
title: '退款记录',
|
||||||
eyebrow: '资金',
|
eyebrow: '资金',
|
||||||
description: '退款结果以服务端审核和资金流水为准',
|
description: '退款结果以服务端审核和资金流水为准',
|
||||||
embedded: true,
|
embedded: true,
|
||||||
viewModel: RecordListViewModel(repository.refunds),
|
sourceKey: 'refunds',
|
||||||
|
loader: widget.repository.refunds,
|
||||||
|
refreshToken: _refundRefreshToken,
|
||||||
),
|
),
|
||||||
RecordListPage(
|
RecordListPage(
|
||||||
|
key: const PageStorageKey('tickets'),
|
||||||
title: '服务工单',
|
title: '服务工单',
|
||||||
eyebrow: '服务',
|
eyebrow: '服务',
|
||||||
description: '查看维修、安装与其他服务工单',
|
description: '查看维修、安装与其他服务工单',
|
||||||
embedded: true,
|
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('提交'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,45 +8,66 @@ class RecordListPage extends StatefulWidget {
|
|||||||
const RecordListPage({
|
const RecordListPage({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.eyebrow,
|
required this.eyebrow,
|
||||||
required this.viewModel,
|
required this.sourceKey,
|
||||||
|
required this.loader,
|
||||||
this.description,
|
this.description,
|
||||||
this.floatingActionButton,
|
this.floatingActionButton,
|
||||||
this.onRecordTap,
|
this.onRecordTap,
|
||||||
this.embedded = false,
|
this.embedded = false,
|
||||||
|
this.refreshToken = 0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final String eyebrow;
|
final String eyebrow;
|
||||||
final String? description;
|
final String? description;
|
||||||
final RecordListViewModel viewModel;
|
final String sourceKey;
|
||||||
|
final RecordLoader loader;
|
||||||
final Widget? floatingActionButton;
|
final Widget? floatingActionButton;
|
||||||
final ValueChanged<ClientRecord>? onRecordTap;
|
final ValueChanged<ClientRecord>? onRecordTap;
|
||||||
final bool embedded;
|
final bool embedded;
|
||||||
|
final int refreshToken;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RecordListPage> createState() => _RecordListPageState();
|
State<RecordListPage> createState() => _RecordListPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RecordListPageState extends State<RecordListPage> {
|
class _RecordListPageState extends State<RecordListPage>
|
||||||
|
with AutomaticKeepAliveClientMixin<RecordListPage> {
|
||||||
|
late RecordListViewModel _viewModel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
widget.viewModel.dispose();
|
_viewModel.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
final content = ListenableBuilder(
|
final content = ListenableBuilder(
|
||||||
listenable: widget.viewModel,
|
listenable: _viewModel,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final state = widget.viewModel;
|
final state = _viewModel;
|
||||||
if (state.loading && state.records.isEmpty) {
|
if (state.loading && state.records.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -123,4 +144,7 @@ class _RecordListPageState extends State<RecordListPage> {
|
|||||||
body: content,
|
body: content,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,23 +14,46 @@ class RecordListViewModel extends ChangeNotifier {
|
|||||||
List<ClientRecord> _records = const [];
|
List<ClientRecord> _records = const [];
|
||||||
Object? _error;
|
Object? _error;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
int _requestGeneration = 0;
|
||||||
|
|
||||||
List<ClientRecord> get records => List.unmodifiable(_records);
|
List<ClientRecord> get records => List.unmodifiable(_records);
|
||||||
Object? get error => _error;
|
Object? get error => _error;
|
||||||
bool get loading => _loading;
|
bool get loading => _loading;
|
||||||
|
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
|
final generation = ++_requestGeneration;
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
_notifyIfActive();
|
||||||
try {
|
try {
|
||||||
_records = await _loader();
|
final records = await _loader();
|
||||||
|
if (!_isCurrent(generation)) return;
|
||||||
|
_records = records;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!_isCurrent(generation)) return;
|
||||||
// 会话失效由统一路由接管,不在列表中重复展示网络错误。
|
// 会话失效由统一路由接管,不在列表中重复展示网络错误。
|
||||||
if (error is! SessionExpiredException) _error = error;
|
if (error is! SessionExpiredException) _error = error;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (_isCurrent(generation)) {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
notifyListeners();
|
_notifyIfActive();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅允许最后一次请求且组件仍存活时写入状态。
|
||||||
|
bool _isCurrent(int generation) => !_disposed && generation == _requestGeneration;
|
||||||
|
|
||||||
|
/// 避免异步请求在页面销毁后继续通知监听器。
|
||||||
|
void _notifyIfActive() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_requestGeneration += 1;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
97
apps/user_app/test/ui/orders_page_test.dart
Normal file
97
apps/user_app/test/ui/orders_page_test.dart
Normal file
@@ -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<List<ClientRecord>> shopOrders() async {
|
||||||
|
shopCalls += 1;
|
||||||
|
return const [
|
||||||
|
ClientRecord(
|
||||||
|
identity: 'shop-1',
|
||||||
|
title: '商城订单一',
|
||||||
|
subtitle: '',
|
||||||
|
status: 18,
|
||||||
|
raw: {
|
||||||
|
'items': [
|
||||||
|
{'identity': 'item-1', 'quantity': 1},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ClientRecord>> gasOrders() async {
|
||||||
|
gasCalls += 1;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ClientRecord>> refunds() async {
|
||||||
|
refundCalls += 1;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ClientRecord>> tickets() async {
|
||||||
|
ticketCalls += 1;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> createRefund({
|
||||||
|
required String business,
|
||||||
|
required String identity,
|
||||||
|
required String requestNo,
|
||||||
|
required String reason,
|
||||||
|
required List<Map<String, Object?>> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
45
apps/user_app/test/ui/record_list_page_test.dart
Normal file
45
apps/user_app/test/ui/record_list_page_test.dart
Normal file
@@ -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<List<ClientRecord>> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
52
apps/user_app/test/ui/record_list_view_model_test.dart
Normal file
52
apps/user_app/test/ui/record_list_view_model_test.dart
Normal file
@@ -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<List<ClientRecord>>();
|
||||||
|
final second = Completer<List<ClientRecord>>();
|
||||||
|
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<List<ClientRecord>>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
47
docs/开发日志_订单列表生命周期修复_20260905.md
Normal file
47
docs/开发日志_订单列表生命周期修复_20260905.md
Normal file
@@ -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 请求本身不强制取消,过期结果会被忽略,不再更新已销毁页面。
|
||||||
|
- 支付发起后不提前假定支付成功,保持既有业务语义。
|
||||||
Reference in New Issue
Block a user