feat(client): pay v2 支付渠道 switch + 订单列表/详情端到端
购买/支付(Phase B): - 新 SegSwitch 段控 widget(镜像 .segswitch 原子) - 购买页重构:顶部支付渠道 switch(默认 USDT→加密货币 / 人民币→支付宝),套餐价随渠道分币种显示,删底部弹窗选方式;点购买直达支付页 - 支付页加订单信息卡(套餐/渠道/金额按币种) - models:PayChannel + PayCatalogItem.priceUsdtMicro/priceLabel(ch)/perMonthLabel(ch) 订单(P0,全新): - payment_api.listOrders() + orders_provider(列表 + 详情 family) - OrdersScreen 列表→详情(状态 pill / 可复制订单号 / 继续支付·重新购买) - 导航:NavView.orders + desktop_shell 分支 + account 入口 配套: - l10n +19 getter(支付渠道 4 + 订单 15)×6 语言,l10n 闸绿 - 图标单源迁移 lucide_icons_flutter + pangolin_icons.dart(codegen) - 联系渠道订正(Telegram/邮件 support@yanmeiai.com/官网) + 前端去广告&时长 - 测试:购买 switch 分币种 + 订单列表/空态/详情 + 假 API listOrders;golden 重录 - flutter analyze 0 error,flutter test 226 全绿 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 62 KiB |
@@ -330,8 +330,9 @@ void main() {
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
// 免费版 10 分钟卡控:连接期倒计时,墙上时钟越过额度即自动切断 + 本地置耗尽(#21)。
|
||||
testWidgets('免费额度倒计时归零 → 自动切断 + 置耗尽', (tester) async {
|
||||
// 免费版 10 分钟卡控(enforcement):连接期锁定额度,墙上时钟越过额度即自动切断 +
|
||||
// 本地置耗尽(#21;额度倒计时 UI 已移除,只保留到点即切的强制行为)。
|
||||
testWidgets('免费额度到点 → 自动切断 + 置耗尽', (tester) async {
|
||||
final bridge = _FakeBridge();
|
||||
var fake = DateTime(2026, 7, 1, 12);
|
||||
final c = makeContainer(bridge, now: () => fake); // 未登录默认免费,剩余 10 分钟
|
||||
@@ -341,7 +342,7 @@ void main() {
|
||||
c.read(nodesProvider);
|
||||
await tester.pump();
|
||||
|
||||
// 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 倒计时(fake api 抛错不影响锁定)。
|
||||
// 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 额度(fake api 抛错不影响锁定)。
|
||||
expect(c.read(quotaProvider).remainingMinutes, 10);
|
||||
c.read(connectionProvider.notifier).toggle();
|
||||
await tester.pump();
|
||||
@@ -349,7 +350,6 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(c.read(connectionProvider).phase, VpnPhase.on);
|
||||
expect(c.read(connectionProvider).freeCountdown, isNotNull, reason: '连接期应有倒计时');
|
||||
|
||||
// 快进墙上时钟越过 10 分钟 → 下一个 1s tick 触发 _refreshElapsed → 切断。
|
||||
fake = fake.add(const Duration(seconds: 601));
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:pangolin_vpn/services/api_client.dart';
|
||||
import 'package:pangolin_vpn/services/auth_api.dart';
|
||||
import 'package:pangolin_vpn/models/payment.dart';
|
||||
import 'package:pangolin_vpn/services/payment_api.dart';
|
||||
|
||||
ApiClient _client(MockClient mock) => ApiClient(
|
||||
@@ -25,7 +26,7 @@ void main() {
|
||||
final items = await api.catalog();
|
||||
expect(items.length, 3);
|
||||
expect(items.first.sku, 'pro_month');
|
||||
expect(items.first.priceLabel(), '¥29.99');
|
||||
expect(items.first.priceLabel(PayChannel.cny), '¥29.99');
|
||||
});
|
||||
|
||||
test('createOrder 只传 sku+method+metadata,解析 session', () async {
|
||||
|
||||
@@ -15,6 +15,9 @@ class _FakePaymentApi implements PaymentApi {
|
||||
@override
|
||||
Future<List<PayCatalogItem>> catalog() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<PayOrderSummary>> listOrders() async => const [];
|
||||
|
||||
@override
|
||||
Future<PayOrder> createOrder({required String sku, required String method, Map<String, String>? metadata}) async {
|
||||
createCalls++;
|
||||
|
||||
@@ -30,7 +30,7 @@ void main() {
|
||||
testWidgets('connecting 态:loader 图标', (tester) async {
|
||||
await tester.pumpWidget(button(VpnPhase.connecting));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.byIcon(PangolinIcons.loader), findsOneWidget);
|
||||
expect(find.byIcon(PangolinIcons.loaderCircle), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('on 态:盾勾 + 计时 + 已加密', (tester) async {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// orders_page_test.dart — 订单列表/详情页行为测试(不打网络,provider 注入)。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||
import 'package:pangolin_vpn/models/payment.dart';
|
||||
import 'package:pangolin_vpn/screens/orders_page.dart';
|
||||
import 'package:pangolin_vpn/state/orders_provider.dart';
|
||||
import 'package:pangolin_vpn/widgets/status_pill.dart';
|
||||
|
||||
import '../helpers/harness.dart';
|
||||
|
||||
final t = StringsZh();
|
||||
|
||||
final _orders = <PayOrderSummary>[
|
||||
PayOrderSummary(
|
||||
orderNo: 'o-year', sku: 'pro_year', plan: 'pro', days: 366, method: 'crypto',
|
||||
status: 'paid', amountMinor: 34990000, currency: 'USDT',
|
||||
createdAt: DateTime.utc(2026, 7, 10), paidAt: DateTime.utc(2026, 7, 10)),
|
||||
PayOrderSummary(
|
||||
orderNo: 'o-month', sku: 'pro_month', plan: 'pro', days: 31, method: 'alipay',
|
||||
status: 'created', amountMinor: 2999, currency: 'CNY',
|
||||
createdAt: DateTime.utc(2026, 7, 11)),
|
||||
PayOrderSummary(
|
||||
orderNo: 'o-q', sku: 'pro_quarter', plan: 'pro', days: 92, method: 'nezha',
|
||||
status: 'canceled', amountMinor: 6888, currency: 'CNY',
|
||||
createdAt: DateTime.utc(2026, 7, 9)),
|
||||
];
|
||||
|
||||
void main() {
|
||||
setUpAll(disableGoogleFontsFetching);
|
||||
|
||||
testWidgets('订单列表:三单渲染 + 结算币种金额 + 状态 pill', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 2400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(wrapThemed(
|
||||
OrdersScreen(t: t, embedded: true),
|
||||
overrides: [ordersProvider.overrideWith((ref) async => _orders)],
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 套餐名(逐档)
|
||||
expect(find.text(t.proYearly), findsOneWidget);
|
||||
expect(find.text(t.proMonthly), findsOneWidget);
|
||||
expect(find.text(t.proQuarterly), findsOneWidget);
|
||||
// 金额按结算币种(USDT $ / CNY ¥),在「金额 · 日期」子行里
|
||||
expect(find.textContaining(r'$34.99'), findsOneWidget);
|
||||
expect(find.textContaining('¥29.99'), findsOneWidget);
|
||||
// 状态 pill(created→等待付款 / paid→已开通 / canceled→已取消)
|
||||
expect(find.byType(StatusPill), findsNWidgets(3));
|
||||
expect(find.text(t.awaitingPayment), findsOneWidget);
|
||||
expect(find.text(t.orderStatusPaid), findsOneWidget);
|
||||
expect(find.text(t.orderStatusCanceled), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('订单空态', (tester) async {
|
||||
await tester.pumpWidget(wrapThemed(
|
||||
OrdersScreen(t: t, embedded: true),
|
||||
overrides: [ordersProvider.overrideWith((ref) async => const <PayOrderSummary>[])],
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(t.ordersEmpty), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('点 created 单进详情:显订单号 + 继续支付', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 2400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(wrapThemed(
|
||||
OrdersScreen(t: t, embedded: true),
|
||||
overrides: [
|
||||
ordersProvider.overrideWith((ref) async => _orders),
|
||||
orderDetailProvider('o-month').overrideWith((ref) async => PayOrderStatus(
|
||||
orderNo: 'o-month', payStatus: 'pending', activated: false, summary: _orders[1])),
|
||||
],
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
// 点 created 那单(月付)进详情
|
||||
await tester.tap(find.text(t.proMonthly));
|
||||
await tester.pumpAndSettle();
|
||||
// 详情字段行 + created 态动作「继续支付」
|
||||
expect(find.text(t.orderNoLabel), findsOneWidget);
|
||||
expect(find.text(t.orderContinuePay), findsWidgets);
|
||||
});
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import 'package:pangolin_vpn/widgets/plan_card.dart';
|
||||
import '../helpers/harness.dart';
|
||||
|
||||
const _items = [
|
||||
PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY'),
|
||||
PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY'),
|
||||
PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'),
|
||||
PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY', priceUsdtMicro: 4990000),
|
||||
PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY', priceUsdtMicro: 12990000),
|
||||
PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY', priceUsdtMicro: 34990000),
|
||||
];
|
||||
|
||||
/// 轮询生命周期测试专用假 API:记 orderStatus / cancel 调用次数,其余按最小实现返回。
|
||||
@@ -26,6 +26,9 @@ class _PollingFakePaymentApi implements PaymentApi {
|
||||
@override
|
||||
Future<List<PayCatalogItem>> catalog() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<PayOrderSummary>> listOrders() async => const [];
|
||||
|
||||
@override
|
||||
Future<PayOrder> createOrder({required String sku, required String method, Map<String, String>? metadata}) async =>
|
||||
const PayOrder(
|
||||
@@ -66,9 +69,15 @@ void main() {
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(PlanCard), findsNWidgets(3));
|
||||
expect(find.text(t.proQuarterly), findsOneWidget);
|
||||
// 默认支付渠道 = USDT → 套餐价显 USDT
|
||||
expect(find.text('\$4.99'), findsOneWidget);
|
||||
expect(find.text('\$34.99'), findsOneWidget);
|
||||
// 切到人民币渠道 → 套餐价改显 ¥
|
||||
await tester.tap(find.text(t.payChannelCny));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('¥29.99'), findsOneWidget);
|
||||
expect(find.text('¥199.99'), findsOneWidget);
|
||||
expect(find.text(t.proQuarterly), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('支付页 crypto_address:地址/金额/复制,金额用 mono', (tester) async {
|
||||
|
||||