Files
jiu/client/lib/screens/license/license_orders_tab.dart
T
wangjia 1723e480c1 fix(client): 订单管理打开自动重拉列表 + 订单时间 UTC 显示转本地时区
- 打开订单管理 tab 时 invalidate 重建列表 provider(保留旧值展示不闪屏),
  新下单/已删单不再需要手动刷新才同步
- license.dart 全部时间字段解析后 toLocal()(同 session.dart 惯例),
  修复订单创建/支付时间显示为 UTC(慢 8 小时)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:39:29 +08:00

939 lines
33 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// screens/license/license_orders_tab.dart — 授权购买订单管理 tab
// (原型 license.html #tab-orders / m-license.html #tab-orders)。
// 数据源:providers/license_purchase_provider.dart。后端 GET /license/purchases
// 只接受 page/page_size/status 三个查询轴;套餐/关键词/下单时间筛选走客户端
// 过滤(当前页),与 stock_out 列表「仓库」筛选同一 established 口径。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/exceptions.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_fonts.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/clock.dart';
import '../../core/utils/money.dart';
import '../../models/license.dart';
import '../../providers/license_provider.dart'
show licenseProvider, licenseRepositoryProvider;
import '../../providers/license_purchase_provider.dart';
import '../../widgets/order_detail_drawer.dart' show showOrderDetailDrawer;
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_kpi.dart';
import '../../widgets/ds/ds_menu.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/ds/ds_toast.dart';
import '../../widgets/ds/m_card.dart';
import '../../widgets/ds/m_kpi_grid.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/status_icon_map.dart' show statusIcon;
import '../../widgets/wheel_date_picker.dart' show showDateRangeDropdown;
import '../../widgets/write_guard.dart';
// bizCode → (展示名, 授权天数):与 core/config/license_plans.dart 的套餐表同源。
const Map<String, (String, int)> _kPlanLabels = {
'promo_first_month': ('首月特惠 · 30 天', 30),
'monthly_standard': ('标准版 · 月付', 30),
'annual_standard': ('标准版 · 年付', 365),
'monthly_pro': ('高级版 · 月付', 30),
'annual_pro': ('高级版 · 年付', 365),
};
String _planLabel(String bizCode) => _kPlanLabels[bizCode]?.$1 ?? bizCode;
int? _planDays(String bizCode) => _kPlanLabels[bizCode]?.$2;
// 状态三态与后端对齐:pending=待支付 / paid=已支付 / failed=已关闭(取消·超时·失败归并)。
const _kStatusLabels = {'pending': '待支付', 'paid': '已支付', 'failed': '已关闭'};
String _statusLabel(String status) => _kStatusLabels[status] ?? status;
DsBadgeTone _statusTone(String status) => switch (status) {
'paid' => DsBadgeTone.ok,
'pending' => DsBadgeTone.warn,
_ => DsBadgeTone.muted,
};
class LicenseOrdersTab extends ConsumerStatefulWidget {
/// 详情面板「查看授权」→ 切回授权信息 tab。
final VoidCallback onGoInfo;
/// 详情面板「重新购买」→ 切到购买续费 tab;null 表示购买 tab 当前不可用
/// iOS 合规态整 tab 隐藏 / 非管理员),此时不渲染该按钮。
final VoidCallback? onGoBuy;
const LicenseOrdersTab({super.key, required this.onGoInfo, this.onGoBuy});
@override
ConsumerState<LicenseOrdersTab> createState() => _LicenseOrdersTabState();
}
class _LicenseOrdersTabState extends ConsumerState<LicenseOrdersTab> {
final _searchCtrl = TextEditingController();
String _query = '';
String _plan = ''; // '' = 全部,否则 bizCode
String _status = ''; // '' = 全部,否则 pending/paid/failed(同步服务端)
DateTimeRange? _dateRange;
String _datePresetLabel = '';
@override
void initState() {
super.initState();
_status = ref.read(purchaseListProvider.notifier).currentStatus;
// 每次打开订单页主动重拉:provider 是全局单例,否则一直显示上次进入时的
// 内存旧列表(新下的单/已删的单不同步,须手动刷新才一致)。
// invalidate 重建保留旧值展示(copyWithPrevious),刷新期间不闪白屏。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) ref.invalidate(purchaseListProvider);
});
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
bool get _hasAnyFilter =>
_query.isNotEmpty ||
_plan.isNotEmpty ||
_status.isNotEmpty ||
_dateRange != null;
void _resetFilters() {
setState(() {
_searchCtrl.clear();
_query = '';
_plan = '';
_status = '';
_dateRange = null;
_datePresetLabel = '';
});
ref.read(purchaseListProvider.notifier).setStatus('');
}
void _setStatus(String status) {
setState(() => _status = status);
ref.read(purchaseListProvider.notifier).setStatus(status);
}
List<PurchaseRecord> _filtered(List<PurchaseRecord> items) {
return items.where((r) {
if (_plan.isNotEmpty && r.productBizCode != _plan) return false;
if (_dateRange != null) {
final d = r.createdAt;
if (d == null) return false;
final day = DateTime(d.year, d.month, d.day);
final start = DateTime(_dateRange!.start.year, _dateRange!.start.month,
_dateRange!.start.day);
final end = DateTime(
_dateRange!.end.year, _dateRange!.end.month, _dateRange!.end.day);
if (day.isBefore(start) || day.isAfter(end)) return false;
}
if (_query.isNotEmpty) {
final q = _query.toLowerCase();
if (!r.outTradeNo.toLowerCase().contains(q) &&
!_planLabel(r.productBizCode).toLowerCase().contains(q)) {
return false;
}
}
return true;
}).toList();
}
@override
Widget build(BuildContext context) {
final async = ref.watch(purchaseListProvider);
return async.when(
skipLoadingOnReload: true,
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.cloudOff, size: 40, color: context.tokens.muted),
const SizedBox(height: 12),
Text('加载失败:$e',
style: TextStyle(color: context.tokens.muted),
textAlign: TextAlign.center),
const SizedBox(height: 12),
DsButton('重试',
variant: DsBtnVariant.primary,
onPressed: () =>
ref.read(purchaseListProvider.notifier).reload()),
],
),
),
data: (result) => Stack(children: [
_buildLoaded(result),
if (async.isLoading) const Positioned.fill(child: DsLoadingScrim()),
]),
);
}
Widget _buildLoaded(PurchaseListResult result) {
final mobile = context.isMobile;
final rows = _filtered(result.items);
final notifier = ref.read(purchaseListProvider.notifier);
if (mobile) {
return Column(
children: [
_buildMobileKpis(result.summary),
_buildMobileSearchRow(),
_buildMobileSection(rows.length),
Expanded(
child: DsTable(
total: result.total,
page: notifier.page,
pageSize: notifier.pageSize,
onPageChanged: (p) => notifier.setPage(p),
onPageSizeChanged: (s) => notifier.setPageSize(s),
emptyText: '没有匹配的授权订单',
columns: _columns,
rows: const [],
mobileCards: rows.map(_orderCard).toList(),
onRefresh: () async {
notifier.reload();
await Future<void>.delayed(const Duration(milliseconds: 600));
},
),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildKpis(result.summary),
const SizedBox(height: 20),
Expanded(
child: DsTable(
total: result.total,
page: notifier.page,
pageSize: notifier.pageSize,
onPageChanged: (p) => notifier.setPage(p),
onPageSizeChanged: (s) => notifier.setPageSize(s),
toolbar: _buildToolbar(),
emptyText: '没有匹配的授权订单 · 试试调整筛选或搜索',
columns: _columns,
rows: rows.map(_row).toList(),
),
),
],
);
}
// ── KPI(原型 .kpis 4 卡)────────────────────────────────────────
Widget _buildKpis(PurchaseSummary summary) {
final lic = ref.watch(licenseProvider).valueOrNull;
final expires = lic?.expiresAt != null
? DateFormat('yyyy-MM-dd').format(lic!.expiresAt!)
: '';
final days = lic?.expiresAt != null
? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999)
: null;
final cards = <Widget>[
DsKpi(
title: '当前授权 · ${lic?.typeLabel ?? ""}',
value: expires,
icon: LucideIcons.shieldCheck,
tone: DsKpiTone.ok,
delta: days != null ? '剩余 $days 天 · 续期自动叠加' : '未激活',
deltaTone: DsKpiDelta.up,
),
DsKpi(
title: '累计购买金额',
value: '¥${yuanFromMinor(summary.paidTotalMinor)}',
icon: LucideIcons.banknote,
tone: DsKpiTone.info,
delta: '已支付 ${summary.paidCount}',
),
DsKpi(
title: '订单总数',
value: '${summary.totalCount}',
icon: LucideIcons.receiptText,
tone: DsKpiTone.info,
),
DsKpi(
title: '待支付 · 点击筛选',
value: '${summary.pendingCount}',
icon: LucideIcons.clock,
tone: DsKpiTone.warn,
delta: summary.pendingCount > 0 ? '未支付订单 2 小时后自动关闭' : '点击筛选',
onTap: () => _setStatus(_status == 'pending' ? '' : 'pending'),
),
];
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < cards.length; i++) ...[
if (i > 0) const SizedBox(width: AppDims.sp3),
Expanded(child: cards[i]),
],
],
),
);
}
Widget _buildMobileKpis(PurchaseSummary summary) {
final lic = ref.watch(licenseProvider).valueOrNull;
final expires = lic?.expiresAt != null
? DateFormat('yyyy-MM-dd').format(lic!.expiresAt!)
: '';
final days = lic?.expiresAt != null
? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999)
: null;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: MKpiGrid(items: [
MKpiItem(
label: '当前授权 · ${lic?.typeLabel ?? ""}',
value: expires,
icon: LucideIcons.shieldCheck,
delta: days != null ? '剩余 $days' : '未激活',
deltaTone: MKpiDeltaTone.up,
),
MKpiItem(
label: '累计购买金额',
value: '¥${yuanFromMinor(summary.paidTotalMinor)}',
icon: LucideIcons.banknote,
delta: '已支付 ${summary.paidCount}',
),
MKpiItem(
label: '待支付 · 点击筛选',
value: '${summary.pendingCount}',
icon: LucideIcons.clock,
delta: summary.pendingCount > 0 ? '2 小时后自动关闭' : '点击筛选',
deltaTone: summary.pendingCount > 0
? MKpiDeltaTone.warn
: MKpiDeltaTone.normal,
selected: _status == 'pending',
onTap: () => _setStatus(_status == 'pending' ? '' : 'pending'),
),
MKpiItem(
label: '订单总数',
value: '${summary.totalCount}',
icon: LucideIcons.receiptText,
),
]),
);
}
// ── 桌面工具栏(原型 .toolbar)───────────────────────────────────
Widget _buildToolbar() {
final searchField = SizedBox(
width: 260,
child: DsSearchBox(
controller: _searchCtrl,
hint: '搜索订单号 / 套餐',
onChanged: (v) => setState(() => _query = v.trim()),
),
);
final planChip = Builder(
builder: (anchorCtx) => DsChip(
label: '套餐',
value: _plan.isEmpty ? null : _planLabel(_plan),
onTap: () async {
final picked = await showDsMenu<String>(anchorCtx, items: [
DsMenuItem(value: '', label: '全部', selected: _plan.isEmpty),
for (final code in _kPlanLabels.keys)
DsMenuItem(
value: code,
label: _planLabel(code),
selected: code == _plan),
]);
if (picked != null) setState(() => _plan = picked);
},
onClear: () => setState(() => _plan = ''),
),
);
final statusChip = Builder(
builder: (anchorCtx) => DsChip(
label: '状态',
value: _status.isEmpty ? null : _statusLabel(_status),
onTap: () async {
final picked = await showDsMenu<String>(anchorCtx, items: [
DsMenuItem(value: '', label: '全部', selected: _status.isEmpty),
for (final s in _kStatusLabels.keys)
DsMenuItem(
value: s, label: _statusLabel(s), selected: s == _status),
]);
if (picked != null) _setStatus(picked);
},
onClear: () => _setStatus(''),
),
);
final dateChip = Builder(
builder: (anchorCtx) => DsChip(
label: '下单时间',
value: _datePresetLabel.isEmpty ? null : _datePresetLabel,
onTap: () => _pickDatePreset(anchorCtx),
onClear: () => setState(() {
_dateRange = null;
_datePresetLabel = '';
}),
),
);
final resetBtn = DsButton(
'重置',
icon: LucideIcons.refreshCw,
small: true,
variant: _hasAnyFilter ? DsBtnVariant.primary : DsBtnVariant.ghost,
onPressed: _resetFilters,
);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
searchField,
const SizedBox(width: AppDims.sp3),
Expanded(
child: Wrap(
spacing: AppDims.sp2,
runSpacing: AppDims.sp2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [planChip, statusChip, dateChip],
),
),
const SizedBox(width: AppDims.sp2),
resetBtn,
],
);
}
Future<void> _pickDatePreset(BuildContext anchorCtx) async {
final picked = await showDsMenu<String>(anchorCtx, items: [
DsMenuItem(value: 'all', label: '全部时间', selected: _datePresetLabel.isEmpty),
const DsMenuItem(value: '7', label: '近 7 天'),
const DsMenuItem(value: '30', label: '近 30 天'),
const DsMenuItem(value: 'month', label: '本月'),
const DsMenuItem(value: 'custom', label: '自定义…'),
]);
if (picked == null) return;
if (picked == 'custom') {
if (!anchorCtx.mounted) return;
final range =
await showDateRangeDropdown(anchorCtx, initial: _dateRange);
if (range == null) return;
setState(() {
_dateRange = range;
_datePresetLabel = '${DateFormat('yyyy-MM-dd').format(range.start)} '
'~ ${DateFormat('yyyy-MM-dd').format(range.end)}';
});
return;
}
final now = appNow();
DateTimeRange? range;
String label = '';
switch (picked) {
case '7':
range =
DateTimeRange(start: now.subtract(const Duration(days: 6)), end: now);
label = '近 7 天';
break;
case '30':
range = DateTimeRange(
start: now.subtract(const Duration(days: 29)), end: now);
label = '近 30 天';
break;
case 'month':
range = DateTimeRange(start: DateTime(now.year, now.month, 1), end: now);
label = '本月';
break;
default:
range = null;
label = '';
}
setState(() {
_dateRange = range;
_datePresetLabel = label;
});
}
// ── 移动搜索行(原型:搜索框 + 状态钮 + 套餐钮)───────────────────
Widget _buildMobileSearchRow() {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: Row(children: [
Expanded(
child: DsSearchBox(
controller: _searchCtrl,
hint: '搜索订单号 / 套餐',
onChanged: (v) => setState(() => _query = v.trim()),
),
),
const SizedBox(width: 8),
DsButton(_status.isEmpty ? '全部' : _statusLabel(_status),
small: true,
variant:
_status.isEmpty ? DsBtnVariant.ghost : DsBtnVariant.primary,
onPressed: _openStatusSheet),
const SizedBox(width: 8),
DsButton(_plan.isEmpty ? '套餐' : _planLabel(_plan),
small: true,
variant: _plan.isEmpty ? DsBtnVariant.ghost : DsBtnVariant.primary,
onPressed: _openPlanSheet),
]),
);
}
Widget _buildMobileSection(int count) {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text('授权订单 · 共 $count',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w700,
letterSpacing: .4,
color: context.tokens.muted)),
),
);
}
Future<void> _openStatusSheet() async {
final options = <(String, String)>[
('', '全部'),
('pending', '待支付'),
('paid', '已支付'),
('failed', '已关闭'),
];
final sel = await showMSheet<String>(
context,
title: '状态筛选',
builder: (ctx) {
final t = ctx.tokens;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < options.length; i++)
InkWell(
onTap: () => Navigator.of(ctx).pop(options[i].$1),
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 13, horizontal: 4),
decoration: BoxDecoration(
border: i < options.length - 1
? Border(bottom: BorderSide(color: t.borderSubtle))
: null,
),
child: Row(children: [
if (options[i].$2 != '全部') ...[
DsIconBadge(options[i].$2),
const SizedBox(width: 10),
],
Text(options[i].$2,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: options[i].$1 == _status
? FontWeight.w600
: FontWeight.w400,
color: options[i].$1 == _status
? t.primary
: t.text)),
const Spacer(),
if (options[i].$1 == _status)
Icon(LucideIcons.check, size: 18, color: t.primary),
]),
),
),
],
);
},
);
if (sel == null || !mounted) return;
_setStatus(sel);
}
Future<void> _openPlanSheet() async {
final options = <(String, String)>[
('', '全部'),
for (final code in _kPlanLabels.keys) (code, _planLabel(code)),
];
final sel = await showMSheet<String>(
context,
title: '套餐筛选',
builder: (ctx) {
final t = ctx.tokens;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < options.length; i++)
InkWell(
onTap: () => Navigator.of(ctx).pop(options[i].$1),
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 13, horizontal: 4),
decoration: BoxDecoration(
border: i < options.length - 1
? Border(bottom: BorderSide(color: t.borderSubtle))
: null,
),
child: Row(children: [
Expanded(
child: Text(options[i].$2,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: options[i].$1 == _plan
? FontWeight.w600
: FontWeight.w400,
color: options[i].$1 == _plan
? t.primary
: t.text)),
),
if (options[i].$1 == _plan)
Icon(LucideIcons.check, size: 18, color: t.primary),
]),
),
),
],
);
},
);
if (sel == null || !mounted) return;
setState(() => _plan = sel);
}
// ── 表格列 / 行(桌面)───────────────────────────────────────────
static const _columns = [
DsColumn('no', '订单号'),
DsColumn('plan', '套餐'),
DsColumn('days', '时长', numeric: true),
DsColumn('amount', '金额', numeric: true),
DsColumn('status', '状态'),
DsColumn('by', '下单人'),
DsColumn('created', '下单时间'),
DsColumn('paid', '支付时间'),
DsColumn('actions', '操作', action: true),
];
DsRow _row(PurchaseRecord r) {
final t = context.tokens;
final days = _planDays(r.productBizCode);
return DsRow(
onTap: () => _showDetail(r),
cells: [
Text(r.outTradeNo,
style: TextStyle(
color: t.faint,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm)),
Text(_planLabel(r.productBizCode)),
Text(days != null ? '$days' : ''),
Text('¥${r.displayAmount}',
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
_statusBadge(r.status),
Text(r.userName.isEmpty ? '' : r.userName),
Text(
r.createdAt != null
? DateFormat('yyyy-MM-dd HH:mm').format(r.createdAt!)
: '',
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm)),
Text(
r.paidAt != null
? DateFormat('yyyy-MM-dd HH:mm').format(r.paidAt!)
: '',
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm,
color: r.paidAt == null ? t.faint : null)),
IconButton(
icon: Icon(LucideIcons.eye, size: 16, color: t.muted),
tooltip: '详情',
splashRadius: 18,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
onPressed: () => _showDetail(r),
),
],
);
}
Widget _statusBadge(String status) => DsBadge(_statusLabel(status),
tone: _statusTone(status), icon: statusIcon(_statusLabel(status)));
MCard _orderCard(PurchaseRecord r) {
final createdText = r.createdAt != null
? DateFormat('yyyy-MM-dd HH:mm').format(r.createdAt!)
: '';
return MCard(
onTap: () => _showDetail(r),
nm: _planLabel(r.productBizCode),
sub: r.outTradeNo,
badges: [DsIconBadge(_statusLabel(r.status))],
amt: '¥${r.displayAmount}',
foot: r.userName.isEmpty ? createdText : '$createdText · ${r.userName}',
);
}
// ── 详情抽屉/sheet ────────────────────────────────────────────
void _showDetail(PurchaseRecord r) {
showOrderDetailDrawer<void>(
context,
builder: (ctx) => _PurchaseDetailPanel(
record: r,
onGoInfo: () {
Navigator.of(ctx).pop();
widget.onGoInfo();
},
onGoBuy: widget.onGoBuy == null
? null
: () {
Navigator.of(ctx).pop();
widget.onGoBuy!();
},
onCancel: () => _cancelOrder(ctx, r),
onContinuePay: () => _continuePay(r),
),
);
}
Future<void> _cancelOrder(BuildContext sheetCtx, PurchaseRecord r) async {
try {
final ok =
await ref.read(licenseRepositoryProvider).cancelPurchase(r.outTradeNo);
if (!mounted) return;
if (ok) {
if (sheetCtx.mounted) Navigator.of(sheetCtx).pop();
ref.read(purchaseListProvider.notifier).reload();
showDsToast(context, '订单已取消', bg: context.tokens.success);
} else {
showDsToast(context, '取消失败,可能已支付,请刷新', bg: context.tokens.danger);
}
} on AppException catch (_) {
if (!mounted) return;
showDsToast(context, '取消失败,可能已支付,请刷新', bg: context.tokens.danger);
}
}
Future<void> _continuePay(PurchaseRecord r) async {
if (r.payUrl.isEmpty) {
if (mounted) showDsToast(context, '支付链接不可用,请刷新后重试');
return;
}
try {
await launchUrl(Uri.parse(r.payUrl), mode: LaunchMode.externalApplication);
} catch (e) {
if (mounted) {
showDsToast(context, '无法打开支付链接,请刷新后重试', bg: context.tokens.danger);
}
}
}
}
/// 订单详情面板内容(桌面右滑抽屉 / 移动底部 sheet 共用同一 body
/// 由 showOrderDetailDrawer 决定外层壳;本面板自带标题行 + 关闭按钮)。
class _PurchaseDetailPanel extends StatelessWidget {
final PurchaseRecord record;
final VoidCallback onGoInfo;
final VoidCallback? onGoBuy;
final Future<void> Function() onCancel;
final Future<void> Function() onContinuePay;
const _PurchaseDetailPanel({
required this.record,
required this.onGoInfo,
required this.onGoBuy,
required this.onCancel,
required this.onContinuePay,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final r = record;
final rows = <(String, Widget)>[
('订单号',
Text(r.outTradeNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback))),
('套餐', Text(_planLabel(r.productBizCode))),
('授权时长', Text('${_planDays(r.productBizCode) ?? ''}')),
('金额',
Text('¥${r.displayAmount}',
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback))),
(
'状态',
DsBadge(_statusLabel(r.status),
tone: _statusTone(r.status), icon: statusIcon(_statusLabel(r.status)))
),
(
'支付渠道',
Text(r.status == 'paid' ? '支付宝' : '',
style: r.status == 'paid' ? null : TextStyle(color: t.faint))
),
('下单人', Text(r.userName.isEmpty ? '' : r.userName)),
(
'下单时间',
Text(
r.createdAt != null
? DateFormat('yyyy-MM-dd HH:mm').format(r.createdAt!)
: '',
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm))
),
(
'支付时间',
r.paidAt != null
? Text(DateFormat('yyyy-MM-dd HH:mm').format(r.paidAt!),
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm))
: Text('', style: TextStyle(color: t.faint))
),
if (r.status == 'paid')
(
'授权续期至',
Text(
r.renewedTo != null
? DateFormat('yyyy-MM-dd').format(r.renewedTo!)
: '',
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w600,
color: t.success))
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.fromLTRB(20, 18, 16, 14),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.border))),
child: Row(children: [
Expanded(
child: Text('订单详情',
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
color: t.heading)),
),
IconButton(
icon: Icon(LucideIcons.x, size: 20, color: t.muted),
splashRadius: 18,
onPressed: () => Navigator.of(context).pop(),
),
]),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final row in rows) _drow(t, row.$1, row.$2),
if (r.status == 'pending') ...[
const SizedBox(height: 14),
_notice(t, '订单未支付将在下单 2 小时后自动关闭;'
'支付成功后授权时长自动叠加到当前到期日,不浪费剩余天数。'),
],
if (r.status == 'failed') ...[
const SizedBox(height: 14),
_notice(t, '订单已关闭。如需购买请重新下单,价格以下单时套餐表为准。'),
],
const SizedBox(height: 18),
_actions(context, t),
],
),
),
),
],
);
}
Widget _drow(dynamic t, String label, Widget value) => Container(
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle))),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
const Spacer(),
DefaultTextStyle(
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.text),
child: value,
),
],
),
);
Widget _notice(dynamic t, String text) => Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: t.infoSoft,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(LucideIcons.info, size: 16, color: t.primary),
const SizedBox(width: 8),
Expanded(
child: Text(text,
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
),
],
),
);
Widget _actions(BuildContext context, dynamic t) {
final buttons = <Widget>[];
switch (record.status) {
case 'pending':
buttons.add(WriteGuard(
child: DsButton('取消订单',
small: true, variant: DsBtnVariant.danger, onPressed: onCancel),
));
buttons.add(DsButton('继续支付',
small: true,
icon: LucideIcons.creditCard,
variant: DsBtnVariant.primary,
onPressed: onContinuePay));
break;
case 'failed':
if (onGoBuy != null) {
buttons.add(DsButton('重新购买',
small: true,
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: onGoBuy));
}
break;
case 'paid':
buttons.add(DsButton('查看授权',
small: true,
icon: LucideIcons.shieldCheck,
onPressed: onGoInfo));
break;
}
if (buttons.isEmpty) return const SizedBox.shrink();
return Wrap(spacing: 10, runSpacing: 10, children: buttons);
}
}