diff --git a/client/lib/core/config/license_plans.dart b/client/lib/core/config/license_plans.dart new file mode 100644 index 0000000..9c23fd5 --- /dev/null +++ b/client/lib/core/config/license_plans.dart @@ -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 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]; +} diff --git a/client/lib/models/license.dart b/client/lib/models/license.dart index 0bf6eaf..bbe4933 100644 --- a/client/lib/models/license.dart +++ b/client/lib/models/license.dart @@ -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 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 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, + ); +} diff --git a/client/lib/repositories/license_repository.dart b/client/lib/repositories/license_repository.dart index 4ccada8..f21a2e5 100644 --- a/client/lib/repositories/license_repository.dart +++ b/client/lib/repositories/license_repository.dart @@ -40,6 +40,34 @@ class LicenseRepository { } } + /// 在线购买/续费下单(仅管理员),返回 pay 收银台跳转信息。 + Future createPurchase(String bizCode) async { + try { + final resp = + await _client.post('/license/purchase', data: {'biz_code': bizCode}); + return PurchaseOrder.fromJson(resp.data['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '下单失败,请稍后重试', + statusCode: e.response?.statusCode, + ); + } + } + + /// 查询购买单状态(到账轮询)。到账(paid)后返回续期后的授权到期时间。 + Future purchaseStatus(String outTradeNo) async { + try { + final resp = await _client.get('/license/purchase/$outTradeNo'); + return PurchaseStatusInfo.fromJson( + resp.data['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '查询订单状态失败', + statusCode: e.response?.statusCode, + ); + } + } + /// Deactivate (unbind) this device from its license. Future deactivate() async { final deviceId = await DeviceId.get(); diff --git a/client/lib/screens/settings/purchase_dialog.dart b/client/lib/screens/settings/purchase_dialog.dart new file mode 100644 index 0000000..db11fea --- /dev/null +++ b/client/lib/screens/settings/purchase_dialog.dart @@ -0,0 +1,310 @@ +// screens/settings/purchase_dialog.dart — App 内在线购买/续费授权。 +// 流程:套餐选择(标准/高级 × 月付/年付)→ POST /license/purchase 下单 → +// 外部浏览器打开 pay 收银台(移动端由系统拉起支付宝 App)→ 本弹窗轮询 +// GET /license/purchase/:otn,webhook 续期到账后自动刷新授权并展示新到期日。 +// 价格仅展示,实付以 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 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 _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 _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]},'); +} diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index b36f63e..310de5d 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -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), // 过期降级说明(现有能力保留) diff --git a/docs/manual/user-manual.html b/docs/manual/user-manual.html index 92cbe42..936a9b6 100644 --- a/docs/manual/user-manual.html +++ b/docs/manual/user-manual.html @@ -413,7 +413,7 @@ 门店信息店名、地址、电话、负责人,管理员可编辑;门店编号不可改。这些信息会印在标签和单据上。 用户管理新增用户(账号+初始密码+角色)、编辑、重置密码、启用/停用。四级角色见第 2 章。仅管理员可操作。 编号规则入库单、出库单等单号的前缀与序号。不要把序号调小到已用过的范围,会撞号。 - 授权兑换券本系统按「时长兑换券」授权:输入形如 JIUKU-XXXX-XXXX 的短码点「兑换续期」,时长直接叠加到现有到期日上,无需订阅。新门店自带 30 天试用。 + 授权兑换券本系统按「时长兑换券」授权:输入形如 JIUKU-XXXX-XXXX 的短码点「兑换续期」,时长直接叠加到现有到期日上,无需订阅。新门店自带 30 天试用。管理员也可点「在线购买 / 续费」选套餐后支付宝支付,到账自动续期。 偏好设置三套主题切换 + 默认仓库(新建单据时自动选中的仓库)。 @@ -447,7 +447,7 @@

早期版本的扫码报错(502 / 页面不存在)已修复。请确认手机有网络;很旧的标签可以在库存页重打一张新标签。

Q7:系统提示授权过期、变成只读了,怎么续?

-

「系统设置 → 授权兑换券」输入 JIUKU- 开头的兑换码点「兑换续期」,立即恢复。没有兑换码请联系客服购买。过期 15 天内数据都还在,别慌。

+

「系统设置 → 授权兑换券」输入 JIUKU- 开头的兑换码点「兑换续期」,或管理员点「在线购买 / 续费」支付宝支付,到账立即恢复。过期 15 天内数据都还在,别慌。

Q8:进货时价格还没谈好,能先入库吗?

能。进价留空提交即可(显示「待定价」),价格定了以后在入库单详情点「确认进价」补上,成本和应付自动补齐。出库同理,售价留空、事后「确认售价」。

diff --git a/web/content/docs.md b/web/content/docs.md index ba2ff22..bf52fda 100644 --- a/web/content/docs.md +++ b/web/content/docs.md @@ -133,7 +133,7 @@ | 门店信息 | 店名/地址/电话/负责人,管理员可编辑,编号不可改 | | 用户管理 | 新增/编辑/重置密码/启停用户,仅管理员 | | 编号规则 | 单号前缀与序号;序号切勿调小,会撞号 | -| 授权兑换券 | 输入 `JIUKU-XXXX-XXXX` 短码兑换续期,时长叠加;新店 30 天试用 | +| 授权兑换券 | 输入 `JIUKU-XXXX-XXXX` 短码兑换续期,时长叠加;新店 30 天试用;管理员也可点「在线购买 / 续费」直接支付宝购买,到账自动续期 | | 偏好设置 | 主题切换 + 默认仓库 | 授权过期:7 天内宽限照常用,7–15 天只读,满 15 天锁定;兑换新券立即恢复,数据不丢。 @@ -156,7 +156,7 @@ **扫码打不开商品页?** 早期版本的扫码报错已修复;确认手机联网,很旧的标签可重打。 -**授权到期变只读?** 「系统设置 → 授权兑换券」输入 JIUKU 兑换码立即恢复;过期 15 天内数据都在。 +**授权到期变只读?** 「系统设置 → 授权兑换券」输入 JIUKU 兑换码,或管理员点「在线购买 / 续费」支付宝支付,到账立即恢复;过期 15 天内数据都在。 **进价/售价还没谈好?** 价格留空即「待定价」,事后在单据详情「确认进价 / 确认售价」补填,账自动补齐。