feat: integrate unified payment and wallet refunds
This commit is contained in:
@@ -79,6 +79,44 @@ class ClientRepository {
|
||||
subtitleKeys: const ['amount', 'direction'],
|
||||
);
|
||||
|
||||
Future<List<ClientRecord>> refunds() => _records(
|
||||
'$root/refunds',
|
||||
titleKeys: const ['refund_no'],
|
||||
subtitleKeys: const ['reason', 'amount'],
|
||||
statusKey: 'refund_status',
|
||||
);
|
||||
|
||||
Future<Map<String, Object?>> payOrder({
|
||||
required String business,
|
||||
required String identity,
|
||||
required String requestNo,
|
||||
required String channel,
|
||||
required String payType,
|
||||
String openid = '',
|
||||
}) async => jsonMap(await _api.post(
|
||||
'$root/$business/orders/$identity/pay',
|
||||
body: {
|
||||
'request_no': requestNo,
|
||||
'channel': channel,
|
||||
'pay_type': payType,
|
||||
if (openid.isNotEmpty) 'openid': openid,
|
||||
},
|
||||
));
|
||||
|
||||
Future<void> createRefund({
|
||||
required String business,
|
||||
required String identity,
|
||||
required String requestNo,
|
||||
required String reason,
|
||||
required List<Map<String, Object?>> items,
|
||||
}) async {
|
||||
await _api.post('$root/$business/orders/$identity/refunds', body: {
|
||||
'request_no': requestNo,
|
||||
'reason': reason,
|
||||
'items': items,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>?> serviceRelation() async {
|
||||
final value = await _api.get('$root/service-relation');
|
||||
if (value == null || value == '') return null;
|
||||
|
||||
2
apps/user_app/lib/data/services/payment_jsapi.dart
Normal file
2
apps/user_app/lib/data/services/payment_jsapi.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'payment_jsapi_stub.dart'
|
||||
if (dart.library.js_interop) 'payment_jsapi_web.dart';
|
||||
2
apps/user_app/lib/data/services/payment_jsapi_stub.dart
Normal file
2
apps/user_app/lib/data/services/payment_jsapi_stub.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
Future<void> invokeWechatJsapi(String clientArgs) =>
|
||||
throw UnsupportedError('微信 JSAPI 仅支持 Web');
|
||||
25
apps/user_app/lib/data/services/payment_jsapi_web.dart
Normal file
25
apps/user_app/lib/data/services/payment_jsapi_web.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:js_interop';
|
||||
|
||||
@JS('WeixinJSBridge.invoke')
|
||||
external void _invoke(String name, JSAny? arguments, JSFunction callback);
|
||||
|
||||
Future<void> invokeWechatJsapi(String clientArgs) {
|
||||
final completer = Completer<void>();
|
||||
final arguments = jsonDecode(clientArgs).jsify();
|
||||
_invoke(
|
||||
'getBrandWCPayRequest',
|
||||
arguments,
|
||||
(JSAny? rawResult) {
|
||||
final result = rawResult.dartify();
|
||||
final message = result is Map ? result['err_msg']?.toString() ?? '' : '';
|
||||
if (message == 'get_brand_wcpay_request:ok') {
|
||||
completer.complete();
|
||||
} else {
|
||||
completer.completeError(StateError('微信支付未完成:$message'));
|
||||
}
|
||||
}.toJS,
|
||||
);
|
||||
return completer.future;
|
||||
}
|
||||
56
apps/user_app/lib/data/services/payment_launcher.dart
Normal file
56
apps/user_app/lib/data/services/payment_launcher.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:fluwx/fluwx.dart';
|
||||
import 'package:tobias/tobias.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'payment_jsapi.dart';
|
||||
|
||||
/// 只负责调起支付渠道;支付结果必须重新向服务端查询确认。
|
||||
class PaymentLauncher {
|
||||
PaymentLauncher({Fluwx? wechat, Tobias? alipay})
|
||||
: _wechat = wechat ?? Fluwx(),
|
||||
_alipay = alipay ?? Tobias();
|
||||
|
||||
final Fluwx _wechat;
|
||||
final Tobias _alipay;
|
||||
|
||||
Future<void> launch(Map<String, Object?> payment) async {
|
||||
final channel = payment['channel'];
|
||||
final payType = payment['pay_type'];
|
||||
final argsText = payment['client_args'] as String? ?? '';
|
||||
|
||||
// 支付宝手机网站支付返回可直接打开的收银台 URL。
|
||||
if (payType == 'wap') {
|
||||
final uri = Uri.tryParse(argsText);
|
||||
if (uri == null ||
|
||||
!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
||||
throw StateError('无法打开支付页面');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kIsWeb && payType == 'jsapi') {
|
||||
await invokeWechatJsapi(argsText);
|
||||
return;
|
||||
}
|
||||
if (channel == 'alipay') {
|
||||
await _alipay.pay(argsText);
|
||||
return;
|
||||
}
|
||||
|
||||
final args = (jsonDecode(argsText) as Map).cast<String, dynamic>();
|
||||
final accepted = await _wechat.pay(
|
||||
which: Payment(
|
||||
appId: payment['app_id'] as String? ?? '',
|
||||
partnerId: args['partnerId'] as String,
|
||||
prepayId: args['prepayId'] as String,
|
||||
packageValue: args['package'] as String,
|
||||
nonceStr: args['nonceStr'] as String,
|
||||
timestamp: int.parse(args['timeStamp'] as String),
|
||||
sign: args['sign'] as String,
|
||||
),
|
||||
);
|
||||
if (!accepted) throw StateError('微信未接受支付请求');
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,172 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../data/repositories/client_repository.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 {
|
||||
const OrdersPage({required this.repository, super.key});
|
||||
OrdersPage({required this.repository, super.key});
|
||||
|
||||
final ClientRepository repository;
|
||||
final PaymentLauncher _launcher = PaymentLauncher();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('我的订单'),
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(text: '商城'),
|
||||
Tab(text: '供气'),
|
||||
Tab(text: '服务工单'),
|
||||
Future<void> _openActions(
|
||||
BuildContext context,
|
||||
ClientRecord record,
|
||||
String business,
|
||||
) async {
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (record.status == 16 || record.status == 18) ...[
|
||||
ListTile(
|
||||
title: const Text('支付宝支付'),
|
||||
onTap: () => Navigator.pop(context, 'alipay'),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('微信支付'),
|
||||
onTap: () => Navigator.pop(context, 'wechat'),
|
||||
),
|
||||
],
|
||||
if (record.status == 18 || record.status == 35)
|
||||
ListTile(
|
||||
title: const Text('申请退款'),
|
||||
onTap: () => Navigator.pop(context, 'refund'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
children: [
|
||||
RecordListPage(
|
||||
title: '商城订单',
|
||||
eyebrow: '交易',
|
||||
viewModel: RecordListViewModel(repository.shopOrders),
|
||||
);
|
||||
if (action == null || !context.mounted) return;
|
||||
try {
|
||||
if (action == 'refund') {
|
||||
final reason = await _reason(context);
|
||||
if (reason == null || !context.mounted) return;
|
||||
final rawItems = record.raw['items'] as List? ?? const [];
|
||||
await repository.createRefund(
|
||||
business: business,
|
||||
identity: record.identity,
|
||||
requestNo: const Uuid().v7(),
|
||||
reason: reason,
|
||||
items: rawItems.map((item) {
|
||||
final value = (item as Map).cast<String, Object?>();
|
||||
return <String, Object?>{
|
||||
'identity': value['identity'],
|
||||
'quantity': value['quantity'] ?? 1,
|
||||
};
|
||||
}).toList(),
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('退款申请已提交')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final payment = await repository.payOrder(
|
||||
business: business,
|
||||
identity: record.identity,
|
||||
requestNo: const Uuid().v7(),
|
||||
channel: action,
|
||||
payType: kIsWeb
|
||||
? (action == 'alipay' ? 'wap' : 'jsapi')
|
||||
: 'app',
|
||||
openid: kIsWeb && action == 'wechat'
|
||||
? Uri.base.queryParameters['openid'] ?? ''
|
||||
: '',
|
||||
);
|
||||
await _launcher.launch(payment);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('支付结果确认中,请稍后刷新')),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error.toString())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _reason(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final value = await showDialog<String>(
|
||||
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('取消'),
|
||||
),
|
||||
RecordListPage(
|
||||
title: '供气订单',
|
||||
eyebrow: '履约',
|
||||
viewModel: RecordListViewModel(repository.gasOrders),
|
||||
),
|
||||
RecordListPage(
|
||||
title: '服务工单',
|
||||
eyebrow: '服务',
|
||||
viewModel: RecordListViewModel(repository.tickets),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
child: const Text('提交'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
controller.dispose();
|
||||
return value?.isEmpty == true ? null : value;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DefaultTabController(
|
||||
length: 4,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('我的订单'),
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(text: '商城'),
|
||||
Tab(text: '供气'),
|
||||
Tab(text: '退款'),
|
||||
Tab(text: '工单'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
children: [
|
||||
RecordListPage(
|
||||
title: '商城订单',
|
||||
eyebrow: '交易',
|
||||
viewModel: RecordListViewModel(repository.shopOrders),
|
||||
onRecordTap: (record) =>
|
||||
_openActions(context, record, 'shop'),
|
||||
),
|
||||
RecordListPage(
|
||||
title: '供气订单',
|
||||
eyebrow: '履约',
|
||||
viewModel: RecordListViewModel(repository.gasOrders),
|
||||
onRecordTap: (record) =>
|
||||
_openActions(context, record, 'gas'),
|
||||
),
|
||||
RecordListPage(
|
||||
title: '退款记录',
|
||||
eyebrow: '资金',
|
||||
viewModel: RecordListViewModel(repository.refunds),
|
||||
),
|
||||
RecordListPage(
|
||||
title: '服务工单',
|
||||
eyebrow: '服务',
|
||||
viewModel: RecordListViewModel(repository.tickets),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/widgets.dart';
|
||||
import '../../../domain/models/client_models.dart';
|
||||
import 'record_list_view_model.dart';
|
||||
|
||||
class RecordListPage extends StatefulWidget {
|
||||
@@ -10,6 +11,7 @@ class RecordListPage extends StatefulWidget {
|
||||
required this.viewModel,
|
||||
this.description,
|
||||
this.floatingActionButton,
|
||||
this.onRecordTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -18,6 +20,7 @@ class RecordListPage extends StatefulWidget {
|
||||
final String? description;
|
||||
final RecordListViewModel viewModel;
|
||||
final Widget? floatingActionButton;
|
||||
final ValueChanged<ClientRecord>? onRecordTap;
|
||||
|
||||
@override
|
||||
State<RecordListPage> createState() => _RecordListPageState();
|
||||
@@ -74,7 +77,10 @@ class _RecordListPageState extends State<RecordListPage> {
|
||||
else
|
||||
SliverList.builder(
|
||||
itemCount: state.records.length,
|
||||
itemBuilder: (context, index) => RecordCard(record: state.records[index]),
|
||||
itemBuilder: (context, index) => RecordCard(
|
||||
record: state.records[index],
|
||||
onTap: widget.onRecordTap == null ? null : () => widget.onRecordTap!(state.records[index]),
|
||||
),
|
||||
),
|
||||
const SliverPadding(padding: EdgeInsets.only(bottom: 24)),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user