1723e480c1
- 打开订单管理 tab 时 invalidate 重建列表 provider(保留旧值展示不闪屏), 新下单/已删单不再需要手动刷新才同步 - license.dart 全部时间字段解析后 toLocal()(同 session.dart 惯例), 修复订单创建/支付时间显示为 UTC(慢 8 小时) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
939 lines
33 KiB
Dart
939 lines
33 KiB
Dart
// 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);
|
||
}
|
||
}
|