252779c607
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 24s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 19s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 45s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 20s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 37s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 15s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m41s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 20s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 12m35s
根因(购买按钮全端「点了没反应」): - 生产 pay-v2 的 CNY 渠道是哪吒(nezha)聚合(底层支付宝/微信),客户端却给 CNY 发 method=alipay → pay 不认 → POST /api/v2/orders 400。USDT/crypto 一直正常。 - 且 _buy 只在 awaitingPayment 时导航,下单失败(phase=failed)既不导航也不弹错, 非 Auth 异常还直接从 start() 抛出未捕获 → 用户看着「没反应」。 改: - PayChannelX.method:cny 'alipay' → 'nezha'(usdt 仍 crypto) - payment_provider._metadata 法币条件同步 nezha;start() 补 catch(_) 兜非 Auth 异常 落 failed(状态机不再卡 creating) - purchase_page._buy:phase==failed 时 showPangolinToast 显式提示 - payment_page 换支付方式的另一渠道 alipay → nezha - 测试钉:PayChannel.cny.method == 'nezha'(发错 method 会被 pay 400) nezha 返回 render_type=redirect(payload.url),客户端现成 redirect 分支直接接; orders_page 早有 'nezha'→payMethodNezha 标签。服务端无需改(method 透传、 SettlementCurrency 只区分 crypto vs 法币)。 验证:flutter analyze 干净;payment 相关 18 测试全过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
260 lines
9.7 KiB
Dart
260 lines
9.7 KiB
Dart
// purchase_page.dart — 购买套餐(三档 pro:月/季/年,pay v2 通道)。
|
|
// 档位数据来自 GET /v1/pay/catalog(server 单源);金额仅展示,扣款以 pay 为准。
|
|
// 支付渠道即币种即方式(USDT→加密货币 默认 / 人民币→支付宝),顶部 SegSwitch 选定,
|
|
// 套餐价随渠道币种刷新;点「立即购买」直接下单进支付页(不再底部弹窗选方式)。
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../l10n/app_text.dart';
|
|
import '../models/payment.dart';
|
|
import '../pangolin_theme.dart';
|
|
import '../state/payment_provider.dart';
|
|
import '../widgets/pangolin_icons.dart';
|
|
import '../widgets/pangolin_toast.dart';
|
|
import '../widgets/plan_card.dart';
|
|
import '../widgets/seg_switch.dart';
|
|
|
|
/// 手写 firstWhere-or-null(避免仅为一次查找引入 package:collection 依赖)。
|
|
T? _firstWhereOrNull<T>(List<T> items, bool Function(T) test) {
|
|
for (final it in items) {
|
|
if (test(it)) return it;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
class PurchaseScreen extends ConsumerStatefulWidget {
|
|
const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false});
|
|
|
|
final AppText t;
|
|
final VoidCallback? onBack;
|
|
/// 下单成功(进入 awaitingPayment)后由壳导航到支付页。
|
|
final VoidCallback? onOrderCreated;
|
|
final bool embedded;
|
|
|
|
@override
|
|
ConsumerState<PurchaseScreen> createState() => _PurchaseScreenState();
|
|
}
|
|
|
|
class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
|
|
AppText get t => widget.t;
|
|
|
|
// 支付渠道:默认 USDT·加密货币。切换即刷新套餐卡显价币种。
|
|
PayChannel _channel = PayChannel.usdt;
|
|
|
|
String _name(String sku) => switch (sku) {
|
|
'pro_month' => t.proMonthly,
|
|
'pro_quarter' => t.proQuarterly,
|
|
'pro_year' => t.proYearly,
|
|
_ => sku,
|
|
};
|
|
|
|
Future<void> _buy(PayCatalogItem item) async {
|
|
await ref.read(paymentFlowProvider.notifier).start(item, _channel.method);
|
|
if (!mounted) return;
|
|
final st = ref.read(paymentFlowProvider);
|
|
if (st.phase == PaymentPhase.awaitingPayment) {
|
|
widget.onOrderCreated?.call();
|
|
} else if (st.phase == PaymentPhase.failed) {
|
|
// 下单失败给可见 toast,别让用户以为「点了没反应」。
|
|
showPangolinToast(
|
|
context,
|
|
t.lang == AppLang.zh
|
|
? (st.errorZh ?? '下单失败,请稍后重试')
|
|
: (st.errorEn ?? 'Failed to create order, please retry'),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
final catalog = ref.watch(payCatalogProvider);
|
|
|
|
final body = catalog.when(
|
|
loading: () => const Center(
|
|
child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())),
|
|
error: (_, __) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(40),
|
|
child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry',
|
|
style: PangolinText.body.copyWith(color: c.fg3)),
|
|
),
|
|
),
|
|
data: (items) {
|
|
final promoItem = _firstWhereOrNull(items, (i) => i.promo);
|
|
final planItems = items.where((i) => !i.promo).toList();
|
|
final originalItem = _firstWhereOrNull(items, (i) => i.sku == 'pro_month');
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 24),
|
|
children: [
|
|
// 支付渠道 SegSwitch(默认 USDT;人民币走支付宝)→ 套餐价随之刷新
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 2, bottom: 8),
|
|
child: Text(t.payChannel.toUpperCase(),
|
|
style: PangolinText.caption.copyWith(
|
|
color: c.fg3, fontWeight: FontWeight.w700, letterSpacing: 0.6)),
|
|
),
|
|
SegSwitch(
|
|
options: [
|
|
(icon: PangolinIcons.globe, label: t.payChannelUsdt),
|
|
(icon: PangolinIcons.creditCard, label: t.payChannelCny),
|
|
],
|
|
selectedIndex: _channel == PayChannel.usdt ? 0 : 1,
|
|
onChanged: (i) => setState(
|
|
() => _channel = i == 0 ? PayChannel.usdt : PayChannel.cny),
|
|
),
|
|
const SizedBox(height: 18),
|
|
if (promoItem != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 14),
|
|
child: _PromoCard(
|
|
t: t,
|
|
channel: _channel,
|
|
item: promoItem,
|
|
originalItem: originalItem,
|
|
onPressed: () => _buy(promoItem),
|
|
),
|
|
),
|
|
for (final it in planItems)
|
|
Padding(
|
|
padding: EdgeInsets.only(bottom: 14, top: it.sku == 'pro_year' ? 12 : 0),
|
|
child: PlanCard(
|
|
name: _name(it.sku),
|
|
price: it.priceLabel(_channel),
|
|
period: '${it.perMonthLabel(_channel)}${t.perMonthSuffix}',
|
|
features: t.featsPro,
|
|
ctaLabel: t.buyNow,
|
|
featured: it.sku == 'pro_year',
|
|
popularLabel: it.sku == 'pro_year' ? t.mostPopular : null,
|
|
onPressed: () => _buy(it),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
if (widget.embedded) return body;
|
|
return Scaffold(
|
|
backgroundColor: c.bg,
|
|
body: SafeArea(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
|
|
child: Row(children: [
|
|
IconButton(
|
|
onPressed: widget.onBack ?? () => Navigator.of(context).maybePop(),
|
|
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
|
|
),
|
|
Text(t.purchaseTitle,
|
|
style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
|
]),
|
|
),
|
|
Expanded(child: body),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// _PromoCard — 全员优惠月付特殊卡片(横幅式,视觉上与普通 PlanCard 拉开层次)。
|
|
/// 徽章 + 大号现价(随渠道 ¥/$ 联动)+ 可选原价划线(以 pro_month 为参照)。
|
|
class _PromoCard extends StatelessWidget {
|
|
const _PromoCard({
|
|
required this.t,
|
|
required this.channel,
|
|
required this.item,
|
|
required this.originalItem,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final AppText t;
|
|
final PayChannel channel;
|
|
final PayCatalogItem item;
|
|
final PayCatalogItem? originalItem;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final originalLabel = originalItem?.priceLabel(channel);
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
|
decoration: BoxDecoration(
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [PangolinColors.clay500, PangolinColors.clay700],
|
|
),
|
|
borderRadius: BorderRadius.circular(PangolinRadius.xl),
|
|
boxShadow: PangolinShadow.md,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: PangolinColors.white.withValues(alpha: 0.22),
|
|
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
|
),
|
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
Icon(PangolinIcons.zap, size: 13, color: PangolinColors.white),
|
|
const SizedBox(width: 4),
|
|
Text(t.promoBadge,
|
|
style: PangolinText.caption.copyWith(
|
|
color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 11)),
|
|
]),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(t.proMonthly,
|
|
style: PangolinText.sm.copyWith(
|
|
color: PangolinColors.white.withValues(alpha: 0.85), fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
children: [
|
|
Text(item.priceLabel(channel),
|
|
style: PangolinText.display.copyWith(color: PangolinColors.white, fontSize: 30)),
|
|
Text(t.perMonthSuffix,
|
|
style: PangolinText.caption.copyWith(
|
|
color: PangolinColors.white.withValues(alpha: 0.85), fontSize: 13)),
|
|
if (originalLabel != null) ...[
|
|
const SizedBox(width: 8),
|
|
Text(originalLabel,
|
|
style: PangolinText.sm.copyWith(
|
|
color: PangolinColors.white.withValues(alpha: 0.62),
|
|
fontSize: 14,
|
|
decoration: TextDecoration.lineThrough,
|
|
)),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: Material(
|
|
color: PangolinColors.white,
|
|
shape: const StadiumBorder(),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: onPressed,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
child: Center(
|
|
child: Text(t.buyNow,
|
|
style: PangolinText.sm.copyWith(
|
|
fontWeight: FontWeight.w700, fontSize: 14, color: PangolinColors.clay700)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|