feat(client): 授权管理独立一级屏——授权/购买续费/订单管理三 tab(桌面+移动)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-11 00:24:59 +08:00
parent 9fe1e2766f
commit 22e07613a1
60 changed files with 1598 additions and 96 deletions
@@ -0,0 +1,926 @@
// 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;
}
@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;
}
await launchUrl(Uri.parse(r.payUrl), mode: LaunchMode.externalApplication);
}
}
/// 订单详情面板内容(桌面右滑抽屉 / 移动底部 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);
}
}
@@ -0,0 +1,193 @@
// screens/license/license_screen.dart — 授权管理独立一级屏(原型 license.html /
// m-license.html),三 tab:授权信息 / 在线购买续费 / 订单管理。
// 桌面 `/license`、移动 `/me/license` 复用同一 Widget,内部按 context.isMobile
// 切换两种形态(桌面:头部+DsSeg+卡片区;移动:通栏 DsSeg+滚动内容)。
//
// tab 可见性:
// - 「在线购买续费」iOS App Store 合规态(hideExternalPurchaseUi)整 tab 隐藏;
// - 「订单管理」GET /license/purchases 仅管理员可用(后端判权),非管理员隐藏该 tab。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/auth/auth_state.dart';
import '../../core/config/store_compliance.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/money.dart';
import '../../core/utils/web_launch.dart';
import '../../providers/license_purchase_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import 'license_orders_tab.dart';
import '../settings/license_panel.dart';
import '../settings/purchase_card.dart';
import '../settings/settings_card.dart';
enum _LicSection { info, buy, orders }
class LicenseScreen extends ConsumerStatefulWidget {
const LicenseScreen({super.key});
@override
ConsumerState<LicenseScreen> createState() => _LicenseScreenState();
}
class _LicenseScreenState extends ConsumerState<LicenseScreen> {
_LicSection _section = _LicSection.info;
void _goInfo() => setState(() => _section = _LicSection.info);
void _goBuy() => setState(() => _section = _LicSection.buy);
@override
Widget build(BuildContext context) {
final t = context.tokens;
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
final isAdmin = role == 'admin' || role == 'superadmin';
final showBuy = !hideExternalPurchaseUi;
final showOrders = isAdmin;
// 当前 tab 若因权限/合规态变得不可见,回退到「授权信息」。
if ((_section == _LicSection.buy && !showBuy) ||
(_section == _LicSection.orders && !showOrders)) {
_section = _LicSection.info;
}
final sections = [
_LicSection.info,
if (showBuy) _LicSection.buy,
if (showOrders) _LicSection.orders,
];
final mobile = context.isMobile;
final segLabels = {
_LicSection.info: '授权信息',
_LicSection.buy: mobile ? '购买 / 续费' : '在线购买 / 续费',
_LicSection.orders: '订单管理',
};
Widget seg = DsSeg(
items: [for (final s in sections) segLabels[s]!],
index: sections.indexOf(_section).clamp(0, sections.length - 1),
onChanged: (i) => setState(() => _section = sections[i]),
);
Widget body = switch (_section) {
_LicSection.info => _infoBody(mobile),
_LicSection.buy => _buyBody(mobile, isAdmin),
_LicSection.orders => LicenseOrdersTab(
onGoInfo: _goInfo,
onGoBuy: showBuy ? _goBuy : null,
),
};
if (mobile) {
return Container(
color: t.bg,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
child: Align(alignment: Alignment.centerLeft, child: seg),
),
const SizedBox(height: 12),
Expanded(child: body),
],
),
);
}
return Container(
color: t.bg,
padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context, ref, t),
const SizedBox(height: 18),
Align(alignment: Alignment.centerLeft, child: seg),
const SizedBox(height: 18),
Expanded(child: body),
],
),
);
}
Widget _buildHeader(BuildContext context, WidgetRef ref, dynamic t) {
final sub = switch (_section) {
_LicSection.info => '授权状态 · 兑换券 · 降级规则',
_LicSection.buy => '选择套餐支付宝支付 · 到账自动续期',
_LicSection.orders => () {
final result = ref.watch(purchaseListProvider).valueOrNull;
if (result == null) return '订单流水';
return '${result.total} 笔 · '
'累计 ¥${yuanFromMinor(result.summary.paidTotalMinor)}';
}(),
};
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('授权管理',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(width: AppDims.sp3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(sub, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
const Spacer(),
if (_section == _LicSection.orders)
DsButton('刷新',
icon: LucideIcons.refreshCw,
onPressed: () =>
ref.read(purchaseListProvider.notifier).reload()),
],
);
}
Widget _infoBody(bool mobile) {
final child = mobile
? const LicensePanel()
: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: const LicensePanel(),
);
return SingleChildScrollView(
padding: mobile ? const EdgeInsets.fromLTRB(14, 0, 14, 14) : EdgeInsets.zero,
child: child,
);
}
Widget _buyBody(bool mobile, bool isAdmin) {
final card = SettingsCard(
title: '在线购买 / 续费',
desc: isAdmin ? '选择套餐支付宝支付,到账后授权自动续期' : '在线购买仅门店管理员可操作',
child: isAdmin
? const PurchaseCard()
: Builder(builder: (ctx) {
final t = ctx.tokens;
return Row(children: [
Text('在线购买请联系门店管理员 · ',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
InkWell(
onTap: () => launchAuthedWebPath(ref, '/#pricing'),
child: Text('查看套餐价格 →',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.primary)),
),
]);
}),
);
final child = mobile
? card
: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 560), child: card);
return SingleChildScrollView(
padding: mobile ? const EdgeInsets.fromLTRB(14, 0, 14, 14) : EdgeInsets.zero,
child: child,
);
}
}