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
+46
View File
@@ -0,0 +1,46 @@
/// 在线购买套餐目录(App 内购买/续费弹窗用)。
///
/// 价格仅前端展示,实付金额以 pay 侧套餐表为准(下单响应回传 amount);
/// biz_code 与后端 payPlans、官网 checkout 页三方一致,改动需同步。
class LicensePlan {
final String bizCode;
final int price; // 元(仅展示)
final int days;
const LicensePlan(this.bizCode, this.price, this.days);
}
class LicensePlanGroup {
final String name;
final List<String> feats;
final LicensePlan monthly;
final LicensePlan annual;
const LicensePlanGroup({
required this.name,
required this.feats,
required this.monthly,
required this.annual,
});
}
class LicensePlans {
static const standard = LicensePlanGroup(
name: '标准版',
feats: ['单门店 · 单仓库', '2 台客户端同时使用', '1,000 张商品图片分享'],
monthly: LicensePlan('monthly_standard', 299, 30),
annual: LicensePlan('annual_standard', 2999, 365),
);
static const pro = LicensePlanGroup(
name: '高级版',
feats: [
'单门店 · 多仓库',
'5 台客户端同时使用',
'10,000 张商品图片分享',
'免费 AI 周度 / 月度商业数据分析',
],
monthly: LicensePlan('monthly_pro', 599, 30),
annual: LicensePlan('annual_pro', 5999, 365),
);
static const groups = [standard, pro];
}
+54
View File
@@ -64,3 +64,57 @@ class LicenseInfo {
bool get needsAttention =>
phase == 'grace' || phase == 'readonly' || phase == 'locked';
}
/// POST /license/purchase 响应:pay 收银台跳转信息。
class PurchaseOrder {
final String payUrl;
final String outTradeNo;
final String amount; // 实付金额(pay 侧权威价,如 "2999.00"
final String subject;
const PurchaseOrder({
required this.payUrl,
required this.outTradeNo,
required this.amount,
required this.subject,
});
factory PurchaseOrder.fromJson(Map<String, dynamic> json) => PurchaseOrder(
payUrl: json['pay_url'] as String? ?? '',
outTradeNo: json['out_trade_no'] as String? ?? '',
amount: json['amount'] as String? ?? '',
subject: json['subject'] as String? ?? '',
);
}
/// GET /license/purchase/:otn 响应:购买单状态(到账轮询用)。
class PurchaseStatusInfo {
final String outTradeNo;
final String status; // pending | paid | failed
final String amount;
final DateTime? paidAt;
final DateTime? expiresAt; // 续期后的门店授权到期时间(仅 paid 时有值)
const PurchaseStatusInfo({
required this.outTradeNo,
required this.status,
required this.amount,
this.paidAt,
this.expiresAt,
});
bool get isPaid => status == 'paid';
factory PurchaseStatusInfo.fromJson(Map<String, dynamic> json) =>
PurchaseStatusInfo(
outTradeNo: json['out_trade_no'] as String? ?? '',
status: json['status'] as String? ?? 'pending',
amount: json['amount'] as String? ?? '',
paidAt: json['paid_at'] != null
? DateTime.tryParse(json['paid_at'] as String)
: null,
expiresAt: json['expires_at'] != null
? DateTime.tryParse(json['expires_at'] as String)
: null,
);
}
@@ -40,6 +40,34 @@ class LicenseRepository {
}
}
/// 在线购买/续费下单(仅管理员),返回 pay 收银台跳转信息。
Future<PurchaseOrder> createPurchase(String bizCode) async {
try {
final resp =
await _client.post('/license/purchase', data: {'biz_code': bizCode});
return PurchaseOrder.fromJson(resp.data['data'] as Map<String, dynamic>);
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '下单失败,请稍后重试',
statusCode: e.response?.statusCode,
);
}
}
/// 查询购买单状态(到账轮询)。到账(paid)后返回续期后的授权到期时间。
Future<PurchaseStatusInfo> purchaseStatus(String outTradeNo) async {
try {
final resp = await _client.get('/license/purchase/$outTradeNo');
return PurchaseStatusInfo.fromJson(
resp.data['data'] as Map<String, dynamic>);
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '查询订单状态失败',
statusCode: e.response?.statusCode,
);
}
}
/// Deactivate (unbind) this device from its license.
Future<void> deactivate() async {
final deviceId = await DeviceId.get();
@@ -0,0 +1,310 @@
// screens/settings/purchase_dialog.dart — App 内在线购买/续费授权。
// 流程:套餐选择(标准/高级 × 月付/年付)→ POST /license/purchase 下单 →
// 外部浏览器打开 pay 收银台(移动端由系统拉起支付宝 App)→ 本弹窗轮询
// GET /license/purchase/:otnwebhook 续期到账后自动刷新授权并展示新到期日。
// 价格仅展示,实付以 pay 侧套餐表为准(下单响应回传 amount)。
import 'dart:async';
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/config/license_plans.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_fonts.dart';
import '../../core/theme/app_tokens.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/dialog_util.dart';
import '../../models/license.dart';
import '../../providers/license_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
Future<void> showPurchaseDialog(BuildContext context) {
return showAppDialog(
context: context,
barrierDismissible: false,
builder: (_) => const _PurchaseDialog(),
);
}
enum _Phase { pick, waiting, success }
class _PurchaseDialog extends ConsumerStatefulWidget {
const _PurchaseDialog();
@override
ConsumerState<_PurchaseDialog> createState() => _PurchaseDialogState();
}
class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
_Phase _phase = _Phase.pick;
int _groupIdx = 0; // 0=标准版 1=高级版
int _cycleIdx = 0; // 0=年付 1=月付
bool _submitting = false;
bool _checking = false;
PurchaseOrder? _order;
DateTime? _newExpiresAt;
Timer? _pollTimer;
LicensePlanGroup get _group => LicensePlans.groups[_groupIdx];
LicensePlan get _plan => _cycleIdx == 0 ? _group.annual : _group.monthly;
String get _cycleLabel => _cycleIdx == 0 ? '年付' : '月付';
@override
void dispose() {
_pollTimer?.cancel();
super.dispose();
}
Future<void> _submit() async {
setState(() => _submitting = true);
try {
final order =
await ref.read(licenseRepositoryProvider).createPurchase(_plan.bizCode);
// 外部浏览器打开收银台:桌面端进网页支付,移动端由系统浏览器拉起支付宝
await launchUrl(Uri.parse(order.payUrl),
mode: LaunchMode.externalApplication);
if (!mounted) return;
setState(() {
_order = order;
_phase = _Phase.waiting;
_submitting = false;
});
_pollTimer =
Timer.periodic(const Duration(seconds: 3), (_) => _poll());
} catch (e) {
if (!mounted) return;
setState(() => _submitting = false);
showDsToast(context, '下单失败:$e', bg: context.tokens.danger);
}
}
Future<void> _poll({bool manual = false}) async {
final order = _order;
if (order == null || _checking) return;
_checking = true;
try {
final st =
await ref.read(licenseRepositoryProvider).purchaseStatus(order.outTradeNo);
if (!mounted) return;
if (st.isPaid) {
_pollTimer?.cancel();
ref.invalidate(licenseProvider);
setState(() {
_newExpiresAt = st.expiresAt;
_phase = _Phase.success;
});
} else if (manual) {
showDsToast(context, '尚未确认到账,支付完成后通常数秒内到账');
}
} catch (e) {
if (mounted && manual) {
showDsToast(context, '查询失败:$e', bg: context.tokens.danger);
}
} finally {
_checking = false;
}
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
return AlertDialog(
title: Text(switch (_phase) {
_Phase.pick => '在线购买 / 续费',
_Phase.waiting => '等待支付',
_Phase.success => '支付成功',
}),
content: SizedBox(
width: context.dialogWidth(460),
child: switch (_phase) {
_Phase.pick => _buildPick(t),
_Phase.waiting => _buildWaiting(t),
_Phase.success => _buildSuccess(t),
},
),
actions: switch (_phase) {
_Phase.pick => [
DsButton('取消',
onPressed:
_submitting ? null : () => Navigator.of(context).pop()),
DsButton(_submitting ? '正在创建订单…' : '提交订单 · ¥${_fmt(_plan.price)}',
variant: DsBtnVariant.primary,
onPressed: _submitting ? null : _submit),
],
_Phase.waiting => [
DsButton('稍后再说', onPressed: () => Navigator.of(context).pop()),
DsButton('我已完成支付',
variant: DsBtnVariant.primary,
onPressed: () => _poll(manual: true)),
],
_Phase.success => [
DsButton('完成',
variant: DsBtnVariant.primary,
onPressed: () => Navigator.of(context).pop()),
],
},
);
}
Widget _buildPick(AppTokens t) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
DsSeg(
items: [for (final g in LicensePlans.groups) g.name],
index: _groupIdx,
onChanged: (i) => setState(() => _groupIdx = i),
),
const SizedBox(width: 10),
DsSeg(
items: const ['年付', '月付'],
index: _cycleIdx,
onChanged: (i) => setState(() => _cycleIdx = i),
),
]),
const SizedBox(height: 14),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final f in _group.feats)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(children: [
Icon(LucideIcons.check, size: 14, color: t.success),
const SizedBox(width: 8),
Expanded(
child: Text(f,
style: TextStyle(
fontSize: AppDims.fsSm, color: t.text))),
]),
),
],
),
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text('${_group.name} · $_cycleLabel · ${_plan.days} 天授权',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
Text('¥${_fmt(_plan.price)}',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: t.heading,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
],
),
const SizedBox(height: 6),
Text('到期时间自动叠加,未到期续费不吃亏 · 支付宝支付',
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
],
);
}
Widget _buildWaiting(AppTokens t) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: t.primary),
),
const SizedBox(width: 12),
Expanded(
child: Text('已在浏览器打开支付宝收银台,完成支付后本窗口会自动确认到账。',
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
),
]),
const SizedBox(height: 14),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.borderSubtle),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('订单号', style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
const SizedBox(height: 4),
Text(_order?.outTradeNo ?? '',
style: const TextStyle(
fontSize: AppDims.fsSm,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
const SizedBox(height: 8),
Text('金额 ¥${_order?.amount ?? ''}',
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
],
),
),
const SizedBox(height: 10),
Text('关闭本窗口不影响到账:支付成功后授权自动续期,可稍后回本页刷新查看。',
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
],
);
}
Widget _buildSuccess(AppTokens t) {
final expires = _newExpiresAt != null
? DateFormat('yyyy-MM-dd').format(_newExpiresAt!)
: null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: t.okSoft,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Icon(LucideIcons.check, size: 20, color: t.success),
),
const SizedBox(width: 12),
Expanded(
child: Text(
expires != null ? '门店授权已续期至 $expires' : '门店授权已续期。',
style: TextStyle(
fontSize: AppDims.fsTitle,
fontWeight: FontWeight.w600,
color: t.heading)),
),
]),
const SizedBox(height: 10),
Text('订单号 ${_order?.outTradeNo ?? ''}',
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
],
);
}
static String _fmt(int n) =>
n.toString().replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+$)'), (m) => '${m[1]},');
}
@@ -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),
// 过期降级说明(现有能力保留)
+2 -2
View File
@@ -413,7 +413,7 @@
<tr><td><b>门店信息</b></td><td>店名、地址、电话、负责人,管理员可编辑;门店编号不可改。这些信息会印在标签和单据上。</td></tr>
<tr><td><b>用户管理</b></td><td>新增用户(账号+初始密码+角色)、编辑、重置密码、启用/停用。四级角色见第 2 章。仅管理员可操作。</td></tr>
<tr><td><b>编号规则</b></td><td>入库单、出库单等单号的前缀与序号。<b>不要把序号调小</b>到已用过的范围,会撞号。</td></tr>
<tr><td><b>授权兑换券</b></td><td>本系统按「时长兑换券」授权:输入形如 <code>JIUKU-XXXX-XXXX</code> 的短码点「兑换续期」,时长直接<b>叠加</b>到现有到期日上,无需订阅。新门店自带 30 天试用。</td></tr>
<tr><td><b>授权兑换券</b></td><td>本系统按「时长兑换券」授权:输入形如 <code>JIUKU-XXXX-XXXX</code> 的短码点「兑换续期」,时长直接<b>叠加</b>到现有到期日上,无需订阅。新门店自带 30 天试用。管理员也可点「在线购买 / 续费」选套餐后支付宝支付,到账自动续期。</td></tr>
<tr><td><b>偏好设置</b></td><td>三套主题切换 + <b>默认仓库</b>(新建单据时自动选中的仓库)。</td></tr>
</tbody>
</table>
@@ -447,7 +447,7 @@
<p class="faq-a">早期版本的扫码报错(502 / 页面不存在)已修复。请确认手机有网络;很旧的标签可以在库存页重打一张新标签。</p>
<p class="faq-q">Q7:系统提示授权过期、变成只读了,怎么续?</p>
<p class="faq-a">「系统设置 → 授权兑换券」输入 <code>JIUKU-</code> 开头的兑换码点「兑换续期」,立即恢复。没有兑换码请联系客服购买。过期 15 天内数据都还在,别慌。</p>
<p class="faq-a">「系统设置 → 授权兑换券」输入 <code>JIUKU-</code> 开头的兑换码点「兑换续期」,或管理员点「在线购买 / 续费」支付宝支付,到账立即恢复。过期 15 天内数据都还在,别慌。</p>
<p class="faq-q">Q8:进货时价格还没谈好,能先入库吗?</p>
<p class="faq-a">能。进价留空提交即可(显示「待定价」),价格定了以后在入库单详情点「确认进价」补上,成本和应付自动补齐。出库同理,售价留空、事后「确认售价」。</p>
+2 -2
View File
@@ -133,7 +133,7 @@
| 门店信息 | 店名/地址/电话/负责人,管理员可编辑,编号不可改 |
| 用户管理 | 新增/编辑/重置密码/启停用户,仅管理员 |
| 编号规则 | 单号前缀与序号;序号切勿调小,会撞号 |
| 授权兑换券 | 输入 `JIUKU-XXXX-XXXX` 短码兑换续期,时长叠加;新店 30 天试用 |
| 授权兑换券 | 输入 `JIUKU-XXXX-XXXX` 短码兑换续期,时长叠加;新店 30 天试用;管理员也可点「在线购买 / 续费」直接支付宝购买,到账自动续期 |
| 偏好设置 | 主题切换 + 默认仓库 |
授权过期:7 天内宽限照常用,7–15 天只读,满 15 天锁定;兑换新券立即恢复,数据不丢。
@@ -156,7 +156,7 @@
**扫码打不开商品页?** 早期版本的扫码报错已修复;确认手机联网,很旧的标签可重打。
**授权到期变只读?** 「系统设置 → 授权兑换券」输入 JIUKU 兑换码立即恢复;过期 15 天内数据都在。
**授权到期变只读?** 「系统设置 → 授权兑换券」输入 JIUKU 兑换码,或管理员点「在线购买 / 续费」支付宝支付,到账立即恢复;过期 15 天内数据都在。
**进价/售价还没谈好?** 价格留空即「待定价」,事后在单据详情「确认进价 / 确认售价」补填,账自动补齐。