feat(client): App 内在线购买/续费授权(管理员支付宝下单+到账轮询)

- 新增套餐目录 license_plans.dart(标准/高级 × 月付/年付,与官网 checkout 一致)
- license 模型/仓库新增 PurchaseOrder/PurchaseStatusInfo 与下单、查单接口
- 系统设置授权面板:管理员显示「在线购买 / 续费」按钮,弹窗选套餐→
  外部浏览器打开支付宝收银台→3s 轮询到账→自动刷新授权并展示新到期日
- 用户手册(docs.md + user-manual.html)同步补充在线购买入口说明

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-03 21:57:03 +08:00
parent e0f721117f
commit 24123eb93c
7 changed files with 460 additions and 12 deletions
@@ -32,6 +32,7 @@ import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_menu.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/write_guard.dart';
import 'purchase_dialog.dart';
import 'users_screen.dart' show userRoleBadge, userStatusBadge;
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_toast.dart';
@@ -685,6 +686,8 @@ class _LicensePanelState extends ConsumerState<_LicensePanel> {
Widget build(BuildContext context) {
final t = context.tokens;
final lic = ref.watch(licenseProvider).valueOrNull;
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
final isAdmin = role == 'admin' || role == 'superadmin';
final expires = lic?.expiresAt != null
? DateFormat('yyyy-MM-dd').format(lic!.expiresAt!)
: '';
@@ -759,14 +762,21 @@ class _LicensePanelState extends ConsumerState<_LicensePanel> {
Row(children: [
Text('没有兑换券?',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
InkWell(
onTap: () => launchUrl(Uri.parse('${AppInfo.website}/#pricing')),
child: Text('购买授权时长 →',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.primary)),
),
// 在线购买仅管理员(后端 purchase 接口 admin only);其他角色引导看官网价格页
if (isAdmin)
DsButton('在线购买 / 续费',
variant: DsBtnVariant.accent,
onPressed: () => showPurchaseDialog(context))
else
InkWell(
onTap: () =>
launchUrl(Uri.parse('${AppInfo.website}/#pricing')),
child: Text('查看套餐价格 →',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.primary)),
),
]),
const SizedBox(height: 16),
// 过期降级说明(现有能力保留)