66422f92ce
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 19s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 23s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 19s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 6s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 34s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 36s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 4s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 5s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m42s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 21s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10m19s
ci-pangolin / Go — build + test (pull_request) Failing after 10m26s
- orders 详情 created 态:继续支付(主)+ 取消订单(ghost)——orders.html/ui-desktop 原型 pending 态早已登记双按钮,Flutter 侧漏实现(代码落后原型)。取消走远程 cancel, 失败(如其实已付,pay 409)不报错,统一 invalidate 刷新以最新状态为准。 - 自动连接「正在自动连接到 X」toast 缺省 3s 用户反馈偏长 → 1.5s(与「已复制」1.4s 同量级)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
430 lines
17 KiB
Dart
430 lines
17 KiB
Dart
// orders_page.dart — 我的订单(列表 → 详情两屏,单 widget 内切换)。
|
|
//
|
|
// 数据走本地台账(ordersProvider / orderDetailProvider);金额/状态文案全经 AppText。
|
|
// App 内无支付表单——「继续支付」= 恢复 created 单的原支付会话(paymentFlowProvider.resume,
|
|
// 走 retry,不新下单);「重新购买」(expired/canceled/paid 复购)才引导到购买页(PurchaseScreen)。
|
|
// 规格参考 design/prototype/screens/orders.html。
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../core/responsive/form_factor.dart';
|
|
import '../l10n/app_text.dart';
|
|
import '../models/payment.dart';
|
|
import '../pangolin_theme.dart';
|
|
import '../state/navigation_provider.dart';
|
|
import '../state/orders_provider.dart';
|
|
import '../state/payment_provider.dart';
|
|
import '../widgets/page_body.dart';
|
|
import '../widgets/sub_scaffold.dart';
|
|
import '../widgets/pangolin_button.dart';
|
|
import '../widgets/pangolin_icons.dart';
|
|
import '../widgets/pangolin_toast.dart';
|
|
import '../widgets/status_pill.dart';
|
|
import 'payment_page.dart';
|
|
import 'purchase_page.dart';
|
|
|
|
// ── 文案映射工具(sku→套餐名 / method→支付方式 / status→状态胶囊)──
|
|
|
|
String _planName(AppText t, PayOrderSummary o) => switch (o.sku) {
|
|
'pro_month' => t.proMonthly,
|
|
'pro_quarter' => t.proQuarterly,
|
|
'pro_year' => t.proYearly,
|
|
_ => o.sku.isNotEmpty ? o.sku : o.plan,
|
|
};
|
|
|
|
String _methodName(AppText t, String method) => switch (method) {
|
|
'crypto' => t.payMethodCrypto,
|
|
'alipay' => t.payMethodAlipay,
|
|
'nezha' => t.payMethodNezha,
|
|
_ => method,
|
|
};
|
|
|
|
/// 订单状态 → (胶囊色, 文案)。created=等待付款 / paid=已开通 / canceled=已取消 / expired=已过期。
|
|
({PangolinStatus status, String label}) _statusPill(AppText t, String status) =>
|
|
switch (status) {
|
|
'created' => (status: PangolinStatus.connecting, label: t.awaitingPayment),
|
|
'paid' => (status: PangolinStatus.connected, label: t.orderStatusPaid),
|
|
'canceled' => (status: PangolinStatus.neutral, label: t.orderStatusCanceled),
|
|
'expired' => (status: PangolinStatus.neutral, label: t.orderStatusExpired),
|
|
_ => (status: PangolinStatus.neutral, label: status),
|
|
};
|
|
|
|
String _fmtDate(DateTime d) {
|
|
final l = d.toLocal();
|
|
String two(int v) => v.toString().padLeft(2, '0');
|
|
return '${l.year}-${two(l.month)}-${two(l.day)}';
|
|
}
|
|
|
|
String _fmtDateTime(DateTime d) {
|
|
final l = d.toLocal();
|
|
String two(int v) => v.toString().padLeft(2, '0');
|
|
return '${l.year}-${two(l.month)}-${two(l.day)} ${two(l.hour)}:${two(l.minute)}';
|
|
}
|
|
|
|
/// 订单页入口:内部在「列表」与「详情」两屏间切换(与原型单帧同构)。
|
|
class OrdersScreen extends ConsumerStatefulWidget {
|
|
const OrdersScreen({super.key, required this.t, this.onBack, this.embedded = false});
|
|
final AppText t;
|
|
final VoidCallback? onBack;
|
|
final bool embedded;
|
|
|
|
@override
|
|
ConsumerState<OrdersScreen> createState() => _OrdersScreenState();
|
|
}
|
|
|
|
class _OrdersScreenState extends ConsumerState<OrdersScreen> {
|
|
PayOrderSummary? _selected; // 非空 = 展示详情
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_selected != null) {
|
|
return _OrderDetailView(
|
|
t: widget.t,
|
|
order: _selected!,
|
|
embedded: widget.embedded,
|
|
onBack: () => setState(() => _selected = null),
|
|
);
|
|
}
|
|
return _OrderListView(
|
|
t: widget.t,
|
|
embedded: widget.embedded,
|
|
onBack: widget.onBack,
|
|
onOpen: (o) => setState(() => _selected = o),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 列表屏 ──
|
|
class _OrderListView extends ConsumerWidget {
|
|
const _OrderListView({required this.t, required this.embedded, required this.onOpen, this.onBack});
|
|
final AppText t;
|
|
final bool embedded;
|
|
final ValueChanged<PayOrderSummary> onOpen;
|
|
final VoidCallback? onBack;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final c = context.pangolin;
|
|
final ordersAsync = ref.watch(ordersProvider);
|
|
|
|
Widget refreshBtn() => Align(
|
|
alignment: Alignment.centerRight,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
|
onTap: () => ref.invalidate(ordersProvider),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
|
decoration: BoxDecoration(
|
|
color: c.bgSubtle,
|
|
borderRadius: BorderRadius.circular(PangolinRadius.full),
|
|
border: Border.all(color: c.border),
|
|
),
|
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
Icon(PangolinIcons.refreshCw, size: 14, color: c.fg2),
|
|
const SizedBox(width: 6),
|
|
Text(t.orderRefresh, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12.5)),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget empty() => Padding(
|
|
padding: const EdgeInsets.only(top: 60),
|
|
child: Column(children: [
|
|
Container(
|
|
width: 56, height: 56,
|
|
decoration: BoxDecoration(color: c.bgSubtle, shape: BoxShape.circle),
|
|
child: Icon(PangolinIcons.ticket, size: 26, color: c.fg3),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(t.ordersEmpty, style: PangolinText.sm.copyWith(color: c.fg3)),
|
|
]),
|
|
);
|
|
|
|
Widget content() => ordersAsync.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.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))),
|
|
data: (orders) => ListView(padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), children: [
|
|
refreshBtn(),
|
|
const SizedBox(height: 12),
|
|
if (orders.isEmpty)
|
|
empty()
|
|
else
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: c.surface,
|
|
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
|
border: Border.all(color: c.border),
|
|
boxShadow: PangolinShadow.sm,
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(children: [
|
|
for (var i = 0; i < orders.length; i++)
|
|
_row(context, c, orders[i], i < orders.length - 1),
|
|
]),
|
|
),
|
|
]),
|
|
);
|
|
|
|
return SubScaffold(title: t.ordersTitle, onBack: onBack, embedded: embedded, wrapPageBody: true, child: content());
|
|
}
|
|
|
|
Widget _row(BuildContext context, PangolinScheme c, PayOrderSummary o, bool divider) {
|
|
final pill = _statusPill(t, o.status);
|
|
final dateStr = o.createdAt != null ? _fmtDate(o.createdAt!) : '';
|
|
final sub = [o.amountLabel(), if (dateStr.isNotEmpty) dateStr].join(' · ');
|
|
return InkWell(
|
|
onTap: () => onOpen(o),
|
|
child: Container(
|
|
decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
|
child: Row(children: [
|
|
Container(
|
|
width: 38, height: 38,
|
|
decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)),
|
|
child: Icon(PangolinIcons.ticket, size: 19, color: c.accent),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
|
Text(_planName(t, o), overflow: TextOverflow.ellipsis,
|
|
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)),
|
|
const SizedBox(height: 2),
|
|
Text(sub, overflow: TextOverflow.ellipsis,
|
|
style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)),
|
|
]),
|
|
),
|
|
const SizedBox(width: 8),
|
|
StatusPill(label: pill.label, status: pill.status),
|
|
const SizedBox(width: 4),
|
|
Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 详情屏 ──
|
|
class _OrderDetailView extends ConsumerWidget {
|
|
const _OrderDetailView({required this.t, required this.order, required this.embedded, required this.onBack});
|
|
final AppText t;
|
|
final PayOrderSummary order;
|
|
final bool embedded;
|
|
final VoidCallback onBack;
|
|
|
|
// 「重新购买」(expired/canceled 重下单;paid 复购)→ 购买页
|
|
// (desktop 内容区下钻;mobile/tablet 全屏 push)。
|
|
void _goPurchase(BuildContext context, WidgetRef ref) {
|
|
final isDesktop = context.formFactor == FormFactor.desktop;
|
|
if (isDesktop) {
|
|
ref.read(navViewProvider.notifier).state = NavView.purchase;
|
|
} else {
|
|
Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => PurchaseScreen(
|
|
t: t,
|
|
onOrderCreated: () => Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => PaymentScreen(
|
|
t: t,
|
|
onDone: () {
|
|
Navigator.of(context).pop();
|
|
// 支付页「完成/取消」回到订单详情:失效列表 + 本单详情,拉新状态。
|
|
ref.invalidate(ordersProvider);
|
|
ref.invalidate(orderDetailProvider(order.orderNo));
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
// 「继续支付」(created 单)→ 恢复原单支付会话(retry),**不**新下单、不经购买页。
|
|
// 成功:awaitingPayment → 打开支付页(desktop 内容区下钻;mobile/tablet 全屏 push)。
|
|
// 失败(含 409 ORDER_NOT_PENDING 自纠——订单其实已变化,常见已被 webhook 开通):
|
|
// 不导航,toast 报错 + 失效列表/详情,让页面刷出订单最新状态。
|
|
Future<void> _resumePay(BuildContext context, WidgetRef ref) async {
|
|
await ref.read(paymentFlowProvider.notifier).resume(order.orderNo, order.method);
|
|
if (!context.mounted) return;
|
|
final flow = ref.read(paymentFlowProvider);
|
|
if (flow.phase == PaymentPhase.failed) {
|
|
final msg = (t.lang == AppLang.zh ? flow.errorZh : flow.errorEn) ?? t.orderContinuePay;
|
|
showPangolinToast(context, msg);
|
|
ref.invalidate(ordersProvider);
|
|
ref.invalidate(orderDetailProvider(order.orderNo));
|
|
return;
|
|
}
|
|
final isDesktop = context.formFactor == FormFactor.desktop;
|
|
if (isDesktop) {
|
|
ref.read(navViewProvider.notifier).state = NavView.payment;
|
|
} else {
|
|
Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => PaymentScreen(
|
|
t: t,
|
|
onDone: () {
|
|
Navigator.of(context).pop();
|
|
// 支付页「完成/取消」回到订单详情:失效列表 + 本单详情,拉新状态。
|
|
ref.invalidate(ordersProvider);
|
|
ref.invalidate(orderDetailProvider(order.orderNo));
|
|
},
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
// 「取消订单」(created 单详情)——远程 cancel 后刷新列表/详情;失败(如已支付,
|
|
// pay 回 409)不报错,同样走刷新,以最新状态为准(已付单会显示已开通)。
|
|
Future<void> _cancelOrder(BuildContext context, WidgetRef ref) async {
|
|
try {
|
|
await ref.read(paymentApiProvider).cancel(order.orderNo);
|
|
} catch (_) {}
|
|
if (!context.mounted) return;
|
|
ref.invalidate(ordersProvider);
|
|
ref.invalidate(orderDetailProvider(order.orderNo));
|
|
}
|
|
|
|
Future<void> _copy(BuildContext context, String text) async {
|
|
await Clipboard.setData(ClipboardData(text: text));
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text(t.copied),
|
|
behavior: SnackBarBehavior.floating,
|
|
duration: const Duration(milliseconds: 1400),
|
|
));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final c = context.pangolin;
|
|
// 富详情(拿新鲜 expiresAt/status);summary 缺省回退列表项。
|
|
final detailAsync = ref.watch(orderDetailProvider(order.orderNo));
|
|
final o = detailAsync.valueOrNull?.summary ?? order;
|
|
final expiresAt = detailAsync.valueOrNull?.expiresAt;
|
|
final pill = _statusPill(t, o.status);
|
|
|
|
// 内容区(mobile 套 SubScaffold;desktop embedded 前置一条返回条回到列表)。
|
|
Widget summaryCard() => Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: c.surface,
|
|
borderRadius: BorderRadius.circular(PangolinRadius.xl),
|
|
border: Border.all(color: c.border),
|
|
boxShadow: PangolinShadow.sm,
|
|
),
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(_planName(t, o), style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 10),
|
|
Text(o.amountLabel(), style: PangolinText.h1.copyWith(color: c.fg1, fontWeight: FontWeight.w700, height: 1)),
|
|
const SizedBox(height: 14),
|
|
StatusPill(label: pill.label, status: pill.status),
|
|
]),
|
|
);
|
|
|
|
Widget kvCard() {
|
|
final rows = <Widget>[
|
|
_kv(c, t.orderNoLabel, o.orderNo, onCopy: () => _copy(context, o.orderNo)),
|
|
_kv(c, t.orderPlanLabel, _planName(t, o)),
|
|
_kv(c, t.orderMethodLabel, _methodName(t, o.method)),
|
|
_kv(c, t.orderAmountLabel, o.amountLabel()),
|
|
if (o.createdAt != null) _kv(c, t.orderCreatedLabel, _fmtDateTime(o.createdAt!)),
|
|
if (o.paidAt != null) _kv(c, t.orderPaidLabel, _fmtDateTime(o.paidAt!)),
|
|
if (expiresAt != null) _kv(c, t.payExpiresAt, _fmtDate(expiresAt)),
|
|
];
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: c.surface,
|
|
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
|
border: Border.all(color: c.border),
|
|
boxShadow: PangolinShadow.sm,
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(children: [
|
|
for (var i = 0; i < rows.length; i++) ...[
|
|
rows[i],
|
|
if (i < rows.length - 1) Divider(height: 1, color: c.border),
|
|
],
|
|
]),
|
|
);
|
|
}
|
|
|
|
Widget actions() {
|
|
final list = <Widget>[];
|
|
switch (o.status) {
|
|
case 'created':
|
|
list.add(PangolinButton(label: t.orderContinuePay, expand: true, onPressed: () => _resumePay(context, ref)));
|
|
// 原型 pending 态为双按钮:继续支付(主)+ 取消订单(ghost)——orders.html/ui-desktop 均已登记。
|
|
list.add(PangolinButton(
|
|
label: t.cancelOrder,
|
|
variant: PangolinButtonVariant.ghost,
|
|
expand: true,
|
|
onPressed: () => _cancelOrder(context, ref)));
|
|
// 过期单 pay 会话已失效,点击是重新下单而非续付 → 用「重新购买」避免歧义。
|
|
case 'expired':
|
|
case 'canceled':
|
|
list.add(PangolinButton(label: t.orderRebuy, expand: true, onPressed: () => _goPurchase(context, ref)));
|
|
case 'paid':
|
|
list.add(PangolinButton(label: t.orderRebuy, variant: PangolinButtonVariant.secondary, expand: true, onPressed: () => _goPurchase(context, ref)));
|
|
}
|
|
if (list.isEmpty) return const SizedBox.shrink();
|
|
return Column(children: [for (final w in list) Padding(padding: const EdgeInsets.only(bottom: 10), child: w)]);
|
|
}
|
|
|
|
Widget body() => ListView(padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), children: [
|
|
summaryCard(),
|
|
const SizedBox(height: 18),
|
|
kvCard(),
|
|
const SizedBox(height: 20),
|
|
actions(),
|
|
]);
|
|
|
|
if (embedded) {
|
|
// desktop 内容区:shell 顶栏返回 = 回账户;这里再给一条返回条回到订单列表。
|
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
InkWell(
|
|
onTap: onBack,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 6),
|
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
Icon(PangolinIcons.arrowLeft, size: 18, color: c.accent),
|
|
const SizedBox(width: 6),
|
|
Text(t.orderDetailTitle, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)),
|
|
]),
|
|
),
|
|
),
|
|
Expanded(child: PageBody(child: body())),
|
|
]);
|
|
}
|
|
return SubScaffold(title: t.orderDetailTitle, onBack: onBack, wrapPageBody: true, child: body());
|
|
}
|
|
|
|
Widget _kv(PangolinScheme c, String label, String value, {VoidCallback? onCopy}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
SizedBox(
|
|
width: 92,
|
|
child: Text(label, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w500)),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(value, textAlign: TextAlign.right,
|
|
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500)),
|
|
),
|
|
if (onCopy != null) ...[
|
|
const SizedBox(width: 8),
|
|
InkWell(
|
|
borderRadius: BorderRadius.circular(PangolinRadius.sm),
|
|
onTap: onCopy,
|
|
child: Padding(padding: const EdgeInsets.all(2), child: Icon(PangolinIcons.copy, size: 15, color: c.fg3)),
|
|
),
|
|
],
|
|
]),
|
|
);
|
|
}
|
|
}
|